Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const schema = z

export default defineAction({
description:
"Search accessible approved Creative Context evidence, optionally within one named context, through portable lexical, Postgres FTS, and same-database pgvector lanes; fuse and diversify results, collapse revisions, and snapshot exact evidence by default.",
"Search accessible approved Creative Context evidence, optionally within one named context, through portable lexical, Postgres FTS, and same-database pgvector lanes; fuse and diversify results, collapse revisions, and snapshot exact evidence by default. A result with nativeArtifact set has a real source artifact behind it, so call get-context-item on that item/version to read version.nativeCode instead of reusing the text excerpt.",
schema,
publicAgent: { expose: true, readOnly: false, requiresAuth: true },
run: performCreativeContextSearch,
Expand Down
16 changes: 14 additions & 2 deletions packages/creative-context/src/client/CreativeContextShareTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,16 @@ function ContextSelect({
<SelectTrigger>
<SelectValue placeholder="Choose a context" />
</SelectTrigger>
<SelectContent>
{/* This tab is embedded inside ShareButton's high z-index popover
(see z-[100010]+ overrides in design/content/slides toolbars).
Without a matching z-index the portal renders behind that popover,
and without data-agent-native-share-overlay the popover's
onInteractOutside treats clicks in this portal as "outside" and
closes the whole Share dialog. */}
<SelectContent
data-agent-native-share-overlay=""
className="z-[100020]"
>
{contexts.map((context) => (
<SelectItem key={context.id} value={context.id}>
{context.name}
Expand Down Expand Up @@ -533,7 +542,10 @@ export function CreativeContextShareTab({
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent
data-agent-native-share-overlay=""
className="z-[100020]"
>
<SelectItem value="canonical">Canonical</SelectItem>
<SelectItem value="exemplar">Exemplar</SelectItem>
<SelectItem value="normal">Reference</SelectItem>
Expand Down
1 change: 1 addition & 0 deletions packages/creative-context/src/server/retrieval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ async function queryImage(input: {
export async function performCreativeContextSearch(
input: CreativeContextSearchInput,
) {
debugger;
const query = input.query?.trim() ?? "";
const image = await queryImage(input);
if (!query && !image)
Expand Down
6 changes: 6 additions & 0 deletions packages/creative-context/src/store/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
stringifyJson,
} from "./helpers.js";
import { createJob, enqueueContextRebuildJob } from "./jobs.js";
import { nativeArtifactSummary } from "./native-artifact-summary.js";

function mapItem(row: any): ContextItemSummary {
return {
Expand Down Expand Up @@ -1142,6 +1143,7 @@ export async function listAccessibleSearchDocuments(
starred: schema.contextItems.starred,
indexState: schema.contextItems.indexState,
parseStatus: schema.contextItemVersions.parseStatus,
versionMetadata: schema.contextItemVersions.metadata,
canonicalUrl: schema.contextItems.canonicalUrl,
mimeType: schema.contextItems.mimeType,
})
Expand Down Expand Up @@ -1225,6 +1227,7 @@ export async function listAccessibleSearchDocuments(
score: 0,
canonicalUrl: row.canonicalUrl ?? null,
mimeType: row.mimeType ?? null,
nativeArtifact: nativeArtifactSummary(row.versionMetadata),
}),
);
if (!documents.length) return documents;
Expand Down Expand Up @@ -1282,6 +1285,7 @@ export async function listAccessibleLexicalCandidates(
results: ContextSearchResult[];
nextCursor?: string;
}> {
debugger;
const { getDb, schema } = getCreativeContext();
const offset = parseOffsetCursor(input.cursor);
const packVersionIds: string[] | null = input.packId
Expand Down Expand Up @@ -1387,6 +1391,7 @@ export async function listAccessibleLexicalCandidates(
body: sql<string>`substr(${schema.contextChunks.text}, 1, 12000)`,
summary: schema.contextItemVersions.summary,
metadata: schema.contextItems.metadata,
versionMetadata: schema.contextItemVersions.metadata,
tags: schema.contextItems.tags,
colors: schema.contextItems.colors,
curationRank: schema.contextItems.curationRank,
Expand Down Expand Up @@ -1454,6 +1459,7 @@ export async function listAccessibleLexicalCandidates(
sourceName: row.sourceName,
kind: row.kind,
title: row.title,
nativeArtifact: nativeArtifactSummary(row.versionMetadata),
excerpt: buildSearchSnippet(row.body, terms, 600),
score:
scoreSearchText(
Expand Down
13 changes: 13 additions & 0 deletions packages/creative-context/src/store/native-artifact-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { nativeCreativeArtifactFromMetadata } from "../native-artifact.js";
import type { ContextSearchResult } from "../types.js";

/**
* Search results advertise a retrievable artifact so a caller can tell that
* get-context-item returns real code rather than only a text snippet.
*/
export function nativeArtifactSummary(
versionMetadata: unknown,
): ContextSearchResult["nativeArtifact"] {
const artifact = nativeCreativeArtifactFromMetadata(versionMetadata);
return artifact ? { app: artifact.app, format: artifact.format } : null;
}
5 changes: 5 additions & 0 deletions packages/creative-context/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,11 @@ export interface ContextSearchResult {
score: number;
canonicalUrl: string | null;
mimeType: string | null;
/**
* Present when the pinned version carries a native artifact, so a caller can
* tell that get-context-item returns real code rather than only a snippet.
*/
nativeArtifact: { app: string; format: string } | null;
}

export interface ContextDetail {
Expand Down
206 changes: 206 additions & 0 deletions plans/design-examples-creative-context/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# Pull past designs in as examples when generating new ones

## Answer

Design already has two context pillars — a design system index and templates
(skeletons like playbooks and LinkedIn ads). The desired third pillar is a
library of **examples**: fully-fledged past designs that are retrieved
automatically and conditioned on when generating net-new work, so "make our
fifth LinkedIn ad" is grounded in the previous four.

That pillar already exists as `@agent-native/creative-context`. It is a
governed, versioned corpus with hybrid retrieval, native code-level reuse, and
per-generation provenance. **Do not build a second vector store.** The
automatic pull-from-examples path is already wired into `generate-design`.

What is missing is not storage or retrieval. It is:

1. a curation front door, so the library is not empty;
2. a hardcoded per-source cap that structurally prevents retrieving four
examples from one source;
3. a binding from a template to a context, so template selection deterministically
selects the right examples; and
4. the template path never resolving context at all.

Four phases of plumbing and UI close all of it. No ranking-algorithm changes,
so no cross-app risk.

## Evidence

### The examples pillar already exists

`@agent-native/creative-context` is a governed creative corpus, not a design
system package ([README.md](../../packages/creative-context/README.md)). Its
object model is four layers:

- **Sources** (`creative_context_sources`) — Google Slides, Figma, Notion,
websites, uploaded files, and *native* app submissions.
- **Items** (`creative_context_items`) — carries `kind`, `tags`, `colors`,
`curationRank` (`canonical | exemplar | normal | ignored`), `starred`, and
`parentItemId` ([schema/index.ts](../../packages/creative-context/src/schema/index.ts)).
- **Versions** (`creative_context_item_versions`) — immutable and
content-hashed, so an old generation can replay exactly what it saw.
- **Packs** (`creative_context_packs`) — the immutable receipt for one
generation: exact `(itemId, itemVersionId)` evidence, lane scores, and
selection reason.

A **Creative Context** is the durable, shareable collection people maintain
(Default, Marketing, Sales). Memberships carry `rank: canonical | exemplar |
normal`, so "these four are the exemplar LinkedIn ads" is already expressible.

Design registers a native capture adapter
([native-creative-context.ts](../../templates/design/server/lib/native-creative-context.ts))
that writes an immutable JSON snapshot and PNG previews to private blob
storage. Crucially, generic retrieval responses never expose the app-native
payload — only Design's typed clone actions
([clone-creative-context-design-native.ts](../../templates/design/actions/clone-creative-context-design-native.ts))
can resolve it. That is what makes real code reuse possible rather than
mood-boarding from screenshots.

### Automatic retrieval already runs

Every `generate-design` call resolves context with no manual input
([generate-design.ts](../../templates/design/actions/generate-design.ts#L291-L294)):

1. Read app state `creative-context`; bail on `contextMode: "off"`, replay
`pinnedPackId` if set.
2. Resolve **Default + at most one specialty**. Precedence: explicit
`selectedContextId` → app binding for the role → `selectSemanticSpecialty`.
3. Search both, fuse with a `+0.15` specialty boost, sort, take top N.
4. Write an immutable pack, return `reuseLabels`.

See [generation-context.ts](../../packages/creative-context/src/server/generation-context.ts#L279-L420).

### Ranking is curation-dominated

Final score is relevance plus `rankQuality()`
([retrieval.ts](../../packages/creative-context/src/server/retrieval.ts#L97-L117),
applied at [#L412](../../packages/creative-context/src/server/retrieval.ts#L412)):

| Signal | Max contribution |
| --- | --- |
| `starred` or `curationRank: canonical` | 1.00 |
| `curationRank: exemplar` | 0.50 |
| recency | 0.03 |
| prior reuse count | 0.04 |
| helpful feedback | 0.04 |

Curation outweighs recency by roughly 30x. The system therefore retrieves the
*best-curated* LinkedIn ads, not the *most recent* ones — and retrieves nothing
useful until someone marks items `exemplar`.

### Retrieval lanes

Three lanes, fused, with `coverage` reported on every response:

- **lexical** — portable normalized grep, weighted title/summary/body.
- **fts** — PostgreSQL `tsvector`/GIN.
- **vector** — pgvector in the same `DATABASE_URL` database. Multimodal: one
family covers text and image, and text queries *are* embedded
([retrieval.ts#L312-L320](../../packages/creative-context/src/server/retrieval.ts#L312-L320)).

The vector lane requires `isPostgres()`, a resolvable
`GEMINI_API_KEY`/`COHERE_API_KEY`/`VOYAGE_API_KEY`
([core/embeddings/index.ts](../../packages/core/src/embeddings/index.ts#L217-L228)),
a dimension-matched active embedding set, and indexed metadata. An image query
without it throws; a text query silently degrades to lexical, reporting
`coverage.vector.available: false`.

## Settled decisions

| Question | Decision | Rationale |
| --- | --- | --- |
| Ranking | **Curation-led.** No changes to `rankQuality()`. | `rankQuality` is shared by Slides, Assets, and Content. Reweighting recency would change retrieval for every app. |
| Template path | **Resolve once at copy time**, attach one `contextPackId` to the generation session. | Per-edit resolution would let different editing passes pull different examples into one design and split provenance across several packs. |
| Deployment | **Postgres + embedding key.** Vector lane is live. | Semantic text matching and image similarity both available. |
| Changeset | **Required.** | `@agent-native/creative-context` is public at 0.6.0 and absent from the `ignore` list in [.changeset/config.json](../../.changeset/config.json). |
| One-specialty cap | **Deferred.** | Default + one specialty covers LinkedIn Ads. Lifting it changes ranking package-wide. |

Two consequences follow. Curation-led ranking makes Phase 4 load-bearing rather
than cosmetic — nothing is retrieved until items are marked `exemplar`. And
because Phase 2 passes `selectedContextId` explicitly, the brittle lexical
name-matching in `selectSemanticSpecialty` is bypassed entirely on the template
path.

## Implementation

Sequence: **4 → 1 → 2 → 3 → 5**, so retrieval is exercised against a real
populated library rather than fixtures.

### Phase 4 — "Save as example" curation UX

The bottleneck. No new action is needed: `review-context-items` already
supports `exemplar`, `star`, `normal`, and `ignore`
([review-context-items.ts](../../packages/creative-context/src/actions/review-context-items.ts#L16-L27),
store mapping at
[content.ts#L2245-L2255](../../packages/creative-context/src/store/content.ts#L2245-L2255)).

Add a "save as example" affordance beside the existing "save as template" on a
finished design. It submits through `manage-context-membership`, then marks the
resulting item `exemplar`.

### Phase 1 — Make `maxPerSource` tunable

`maxPerSource: 3` is hardcoded at
[generation-context.ts#L353](../../packages/creative-context/src/server/generation-context.ts#L353).
Every design submitted natively from Design shares one source, so the cap makes
"retrieve four ads" impossible.

Add `maxPerSource?: number` to `ResolveGenerationCreativeContextInput`
([#L159-L168](../../packages/creative-context/src/server/generation-context.ts#L159-L168))
and thread it into `searchInput`. Default stays 3, so Slides, Assets, and
Content are unaffected. `performCreativeContextSearch` already accepts the
field — this is pure plumbing. Needs a changeset.

### Phase 2 — Bind a creative context to a design template

Add an additive `contextId` column to `designTemplates`
([schema.ts#L30-L72](../../templates/design/server/db/schema.ts#L30-L72)) plus an
additive migration in
[db.ts](../../templates/design/server/plugins/db.ts), mirroring the existing
`designSystemId` link. `create-design-from-template` sets it on the design and
generation session; `generate-design` passes it as `selectedContextId`.

**No resolver changes are required.** `selectedContextId` is already an accepted
input
([#L166](../../packages/creative-context/src/server/generation-context.ts#L166))
and is honored on the local path at
[#L327](../../packages/creative-context/src/server/generation-context.ts#L327),
taking precedence over both the app binding and `selectSemanticSpecialty`.

### Phase 3 — Resolve context on the template path

Blocked on Phase 2.

`create-design-from-template` returns `nextRequiredAction` → `get-design-snapshot`
+ `edit-design`, so `resolveGenerationCreativeContext` never runs and the
template path retrieves zero examples. This is the one flow that most needs
examples and currently gets none.

Resolve context once at template-copy time and attach the `contextPackId` to the
generation session so every downstream edit and variant inherits the same
immutable snapshot. This matches the existing rule in the `creative-context`
skill: keep one explainable snapshot across every screen and variant.

### Phase 5 — Update Design skills

Per the `adding-a-feature` four-area checklist, document the behavior in
`templates/design/.agents/skills/creative-context/SKILL.md` and the
`design-templates` skill: templates may carry a bound context, template selection
sets `selectedContextId`, and the template path resolves one pack at copy time.
Mirror into `templates/design/.claude/skills/`. Read `writing-agent-instructions`
first.

## Open loops

- **Recency.** If "the last four" ever needs to mean literal recency rather than
curation quality, use the existing `updatedAfter` search filter rather than
reweighting the shared `rankQuality()`.
- **Specialty naming.** `selectSemanticSpecialty`
([#L68-L88](../../packages/creative-context/src/server/generation-context.ts#L68-L88))
is lexical name/description token matching, not embeddings, despite the name.
Phase 2 bypasses it on the template path, but free-form prompts
("make a paid social creative") still depend on it matching a context name.
- **Multiple specialties.** Combining a LinkedIn Ads context with a campaign
context simultaneously is out of scope and would require lifting the
Default-plus-one cap.
1 change: 1 addition & 0 deletions templates/design/actions/generate-design.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ async function resolveDesignCreativeContext(input: {
contextModeOverride?: "off";
reuseLabels: CreativeContextReuseLabel[];
}): Promise<DesignCreativeContextProvenance> {
debugger;
if (input.contextModeOverride === "off") {
const validated = await validateGenerationCreativeContext({
contextPackId: input.contextPackId,
Expand Down
Loading
Loading