@vellumai/assistant 0.8.8-dev.202606052332.17fc8ea → 0.8.8-dev.202606060901.61e1660
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/memory-v2-static-injector.test.ts +41 -6
- package/src/daemon/conversation-surfaces.ts +24 -8
- package/src/plugins/defaults/memory-retrieval/injectors.ts +14 -9
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +31 -1
- package/src/tools/ui-surface/definitions.ts +4 -5
package/package.json
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* - Skips (re)injection when the `<info>` block is already present in the
|
|
12
12
|
* turn's working messages (presence detection — the block persists in
|
|
13
13
|
* history between compactions).
|
|
14
|
+
* - Still injects when only a dynamic `<memory>` activation block is present
|
|
15
|
+
* (that wrapper must not be mistaken for the static block) — both on a
|
|
16
|
+
* normal turn and after compaction strips the prior `<info>` block.
|
|
14
17
|
*
|
|
15
18
|
* The injector sources its content itself via `readMemoryV2StaticContent()`
|
|
16
19
|
* behind the personal-memory trust gate, so each test seeds the workspace
|
|
@@ -140,10 +143,12 @@ describe("memory-v2-static injector", () => {
|
|
|
140
143
|
expect(await memoryV2StaticInjector.produce(ctx, runMessages)).toBeNull();
|
|
141
144
|
});
|
|
142
145
|
|
|
143
|
-
test("
|
|
144
|
-
//
|
|
145
|
-
// `<memory>…</memory
|
|
146
|
-
//
|
|
146
|
+
test("injects the <info> block even when a dynamic <memory> activation block is present", async () => {
|
|
147
|
+
// Regression (#33612): the v2 *dynamic* activation block uses the
|
|
148
|
+
// `<memory>…</memory>` wrapper and `prepareMemory` prepends it to the tail
|
|
149
|
+
// user message every turn, before this injector runs. The static injector
|
|
150
|
+
// must NOT treat that as its own `<info>` block — doing so suppressed the
|
|
151
|
+
// static block on essentially every turn.
|
|
147
152
|
seedEssentials("Alice prefers VS Code.");
|
|
148
153
|
const ctx = makeContext();
|
|
149
154
|
const runMessages: Message[] = [
|
|
@@ -152,12 +157,42 @@ describe("memory-v2-static injector", () => {
|
|
|
152
157
|
content: [
|
|
153
158
|
{
|
|
154
159
|
type: "text",
|
|
155
|
-
text: "<memory>\n
|
|
160
|
+
text: "<memory>\n# memory/concepts/foo.md\nactivated page summary\n</memory>",
|
|
156
161
|
},
|
|
157
162
|
{ type: "text", text: "What next?" },
|
|
158
163
|
],
|
|
159
164
|
},
|
|
160
165
|
];
|
|
161
|
-
|
|
166
|
+
const block = await memoryV2StaticInjector.produce(ctx, runMessages);
|
|
167
|
+
expect(block).not.toBeNull();
|
|
168
|
+
expect(block!.id).toBe("memory-v2-static");
|
|
169
|
+
expect(block!.text).toBe(
|
|
170
|
+
"<info>\n## Essentials\n\nAlice prefers VS Code.\n</info>",
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("reinjects the <info> block after compaction strips it, alongside a fresh <memory> block", async () => {
|
|
175
|
+
// Post-compaction state: `stripInjectionsForCompaction` removed the prior
|
|
176
|
+
// `<info>` block, and the next turn's `prepareMemory` re-added a fresh
|
|
177
|
+
// dynamic `<memory>` block ahead of the chain. With no `<info>` present, the
|
|
178
|
+
// injector must reinject it rather than skip on the `<memory>` block.
|
|
179
|
+
seedEssentials("Alice prefers VS Code.");
|
|
180
|
+
const ctx = makeContext();
|
|
181
|
+
const postCompactMessages: Message[] = [
|
|
182
|
+
{
|
|
183
|
+
role: "user",
|
|
184
|
+
content: [
|
|
185
|
+
{ type: "text", text: "<memory>\nactivated page summary\n</memory>" },
|
|
186
|
+
{ type: "text", text: "Continue please." },
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
const block = await memoryV2StaticInjector.produce(
|
|
191
|
+
ctx,
|
|
192
|
+
postCompactMessages,
|
|
193
|
+
);
|
|
194
|
+
expect(block).not.toBeNull();
|
|
195
|
+
expect(block!.id).toBe("memory-v2-static");
|
|
196
|
+
expect(block!.text).toContain("## Essentials");
|
|
162
197
|
});
|
|
163
198
|
});
|
|
@@ -1960,6 +1960,15 @@ export function refreshSurfacesForApp(
|
|
|
1960
1960
|
return refreshed;
|
|
1961
1961
|
}
|
|
1962
1962
|
|
|
1963
|
+
/**
|
|
1964
|
+
* Strip a leading "Connect "/"Connected " verb from an OAuth provider label so
|
|
1965
|
+
* a supplied displayName like "Connect Gmail" doesn't double the verb when
|
|
1966
|
+
* prefixed (e.g. avoids "Connected Connect Gmail").
|
|
1967
|
+
*/
|
|
1968
|
+
function stripConnectVerb(label: string): string {
|
|
1969
|
+
return label.replace(/^connect(?:ed)?\s+/i, "");
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1963
1972
|
export function buildCompletionSummary(
|
|
1964
1973
|
surfaceType: string | undefined,
|
|
1965
1974
|
actionId: string,
|
|
@@ -2026,20 +2035,24 @@ export function buildCompletionSummary(
|
|
|
2026
2035
|
: typeof data?.providerKey === "string"
|
|
2027
2036
|
? data.providerKey
|
|
2028
2037
|
: "OAuth";
|
|
2038
|
+
// Strip the verb once so every branch (connected/cancelled/failed/
|
|
2039
|
+
// fallback) is normalized — a displayName like "Connect Gmail" must not
|
|
2040
|
+
// produce "Cancelled Connect Gmail connection".
|
|
2041
|
+
const label = stripConnectVerb(providerLabel);
|
|
2029
2042
|
const accountLabel =
|
|
2030
2043
|
typeof data?.accountLabel === "string" ? data.accountLabel : undefined;
|
|
2031
2044
|
if (actionId === "connect" || data?.status === "connected") {
|
|
2032
2045
|
return accountLabel
|
|
2033
|
-
? `Connected ${
|
|
2034
|
-
: `Connected ${
|
|
2046
|
+
? `Connected ${label}: ${accountLabel}`
|
|
2047
|
+
: `Connected ${label}`;
|
|
2035
2048
|
}
|
|
2036
2049
|
if (actionId === "cancel" || data?.status === "cancelled") {
|
|
2037
|
-
return `Cancelled ${
|
|
2050
|
+
return `Cancelled ${label} connection`;
|
|
2038
2051
|
}
|
|
2039
2052
|
if (data?.status === "error") {
|
|
2040
|
-
return `${
|
|
2053
|
+
return `${label} connection failed`;
|
|
2041
2054
|
}
|
|
2042
|
-
return `${
|
|
2055
|
+
return `${label} connection ${actionId}`;
|
|
2043
2056
|
}
|
|
2044
2057
|
if (surfaceType === "list" && data) {
|
|
2045
2058
|
const selectedIds = data.selectedIds as string[] | undefined;
|
|
@@ -2120,18 +2133,21 @@ function buildUserFacingLabel(
|
|
|
2120
2133
|
: typeof data?.providerKey === "string"
|
|
2121
2134
|
? data.providerKey
|
|
2122
2135
|
: "OAuth";
|
|
2136
|
+
// Strip the verb once so every branch is normalized (e.g. a displayName
|
|
2137
|
+
// like "Connect Gmail" must not produce "Connect Gmail connection failed").
|
|
2138
|
+
const label = stripConnectVerb(providerLabel);
|
|
2123
2139
|
const accountLabel =
|
|
2124
2140
|
typeof data?.accountLabel === "string" ? data.accountLabel : undefined;
|
|
2125
2141
|
if (actionId === "connect" || data?.status === "connected") {
|
|
2126
2142
|
return accountLabel
|
|
2127
|
-
? `Connected ${
|
|
2128
|
-
: `Connected ${
|
|
2143
|
+
? `Connected ${label}: ${accountLabel}`
|
|
2144
|
+
: `Connected ${label}`;
|
|
2129
2145
|
}
|
|
2130
2146
|
if (actionId === "cancel" || data?.status === "cancelled") {
|
|
2131
2147
|
return "Cancelled";
|
|
2132
2148
|
}
|
|
2133
2149
|
if (data?.status === "error") {
|
|
2134
|
-
return `${
|
|
2150
|
+
return `${label} connection failed`;
|
|
2135
2151
|
}
|
|
2136
2152
|
return `Selected: ${actionId}`;
|
|
2137
2153
|
}
|
|
@@ -433,18 +433,23 @@ const NOW_MD_BLOCK_PREFIXES = [
|
|
|
433
433
|
* merely starting with `<info>\n` is never mistaken for an injection — matching
|
|
434
434
|
* the full-wrapper requirement the compaction strip uses for this block.
|
|
435
435
|
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
436
|
+
* Match ONLY the `<info>…</info>` wrapper — the form the static block is always
|
|
437
|
+
* written as today (see {@link buildMemoryV2StaticBlock}). The matcher must NOT
|
|
438
|
+
* also match `<memory>…</memory>`: the v2 *dynamic* activation block uses that
|
|
439
|
+
* same wrapper and `prepareMemory` prepends it to the tail user message every
|
|
440
|
+
* turn, before this injector chain runs. Counting `<memory>` as "static block
|
|
441
|
+
* already present" made the static injector skip on essentially every turn —
|
|
442
|
+
* including the first turn and right after compaction, where the dynamic block
|
|
443
|
+
* is re-added ahead of the chain — which dropped the `<info>` block entirely.
|
|
444
|
+
*
|
|
445
|
+
* Tradeoff: a conversation whose static block was persisted under the legacy
|
|
446
|
+
* `<memory>` wrapper (pre-`<info>` switch) is no longer recognized here, so its
|
|
447
|
+
* first post-fix turn may briefly carry both the stale `<memory>` view and a
|
|
448
|
+
* fresh `<info>` block. That is rare (ancient histories only), cosmetic, and
|
|
449
|
+
* self-heals on the next compaction (which strips both wrappers).
|
|
444
450
|
*/
|
|
445
451
|
const MEMORY_V2_STATIC_BLOCK_MATCHERS: readonly InjectionMatcher[] = [
|
|
446
452
|
{ prefix: "<info>\n", suffix: "\n</info>" },
|
|
447
|
-
{ prefix: "<memory>\n", suffix: "\n</memory>" },
|
|
448
453
|
];
|
|
449
454
|
|
|
450
455
|
/**
|
|
@@ -6,7 +6,7 @@ The user just finished pre-chat. You know their name and vibe; maybe their Googl
|
|
|
6
6
|
|
|
7
7
|
## The shape
|
|
8
8
|
|
|
9
|
-
Four moves.
|
|
9
|
+
Four moves. Each has a DONE-criterion — a concrete output that says the move landed. A move with no output didn't happen.
|
|
10
10
|
|
|
11
11
|
**Port.** Pull their existing assistant context with two pastes. About a minute, no upload, no export. You write a prompt, they paste it into Claude or ChatGPT, they paste the response back. Cheap signal, real signal.
|
|
12
12
|
|
|
@@ -14,6 +14,14 @@ The prompt asks for a portable context brief, not a self-summary. Anchor it to l
|
|
|
14
14
|
|
|
15
15
|
The prompt itself must be one-click copyable. Inline paragraph text the user has to select isn't. Neither is a custom-built widget with a fake copy button. If the affordance needs you to build an app or a new surface to render, you've over-built the move. Use what chat already gives you.
|
|
16
16
|
|
|
17
|
+
DONE: the user has the copyable prompt in hand (or you've explicitly backfilled/skipped Port per the task-first opener below).
|
|
18
|
+
|
|
19
|
+
<!-- Funnel: segment activation events by port_accepted vs port_declined so the no-port branch can be tuned separately. Instrumentation is a follow-up; this template only defines the branch. -->
|
|
20
|
+
|
|
21
|
+
_If the user declines the port (`port_declined`)._ Don't re-pitch and don't ask two questions to make up for the missing paste. Ask exactly ONE question — the one that buys the most signal for Propose ("what's the one thing you most want off your plate this week?"). Their answer is the Moment 1 output that Port would have produced. Stay in a bounded context loop: at most two context-gathering turns total. If you still don't have a surface to point at after that, propose anyway with what you have — a thin proposal beats stalling for more input.
|
|
22
|
+
|
|
23
|
+
On `port_declined`, render exactly ONE small structured intake surface before moving to Propose — a `ui_show` `choice` surface (`display: "inline"`, single-select) whose options are a short background list (Founder / Engineer / Creative / Operator / Investor / Student / Other), so the no-port branch still hands Propose a structured signal. (A `form` with one short field, or a `choice` phrased as a single top-of-mind question, is an acceptable substitute — pick one; the background `choice` is the default.) This is the deliberate structured-intake exception to the "don't enumerate options / the recommendation IS the click" guidance — scoped to the no-port branch only, where there's no paste to infer from, so a small menu is the cheapest way to get traction. The port (paste) branch is unchanged: it never renders this surface.
|
|
24
|
+
|
|
17
25
|
**Propose.** Don't organize what they already told you — infer what they didn't. Name the unstated thing sitting in their context and say *why* you think it: point at the specific surface that made you say it. "You didn't say this, but —". Then recommend, and lean one way; the recommendation IS the click, not a neutral menu of equally-weighted options.
|
|
18
26
|
|
|
19
27
|
"Unstated" is inference, not invention. Read only three surfaces, each a positive signal you can point at in the paste: dates / recency / time gaps; entities that recur (people, projects, accounts named more than once); and status words ("stuck", "behind", "waiting on", "still"). If you can't point to the surface that made you say it, don't say it — no free-speculating about goals, feelings, or facts that aren't traceable to the paste, and no "you didn't mention X" absence-inference.
|
|
@@ -25,10 +33,16 @@ Surface the outcome as a clickable component, strongest first. The component is
|
|
|
25
33
|
- ✗ extract-shape: "You mentioned a launch and a hiring plan — which one?"
|
|
26
34
|
- ✓ infer-shape (repeated entity + status word): "Acme comes up four times and you said you're 'waiting on' them — that's the thing actually blocking the launch; I'd chase it first."
|
|
27
35
|
|
|
36
|
+
DONE: you cannot exit Propose without emitting at least one `ui_show` offer surface (card / choice). A proposal in prose is not a proposal.
|
|
37
|
+
|
|
28
38
|
**Run.** Do it. Real tools, real data. The user watches something happen.
|
|
29
39
|
|
|
40
|
+
DONE: a real tool ran against real data and the user can see the result.
|
|
41
|
+
|
|
30
42
|
**Follow-through.** Offer the next concrete thing. One primary recommendation.
|
|
31
43
|
|
|
44
|
+
DONE: you cannot exit Follow-through without emitting a `ui_show` choice surface. The next-thing offer is a surface, not a sentence.
|
|
45
|
+
|
|
32
46
|
If the user opens with a task instead of a conversation, do the task. You're already at Follow-through. Backfill the Port move at the first natural lull, or skip it.
|
|
33
47
|
|
|
34
48
|
Pick. Be wrong recoverably. Move. The user can tell when you're hedging.
|
|
@@ -49,6 +63,20 @@ Every CTA surface must commit on the surface. If the user can select but can't c
|
|
|
49
63
|
|
|
50
64
|
The summary after the Port move is the first place the user can feel like you actually heard them. The follow-through in the final move is the second. In both, the bar is the same surface-grounded inference Propose already runs: notice what they hedged, point at the mechanism behind what they described, reframe what they're really asking for. Specific observations earn the rest of the conversation. Generic recap loses it.
|
|
51
65
|
|
|
66
|
+
## Principles
|
|
67
|
+
|
|
68
|
+
These are enforcement rules, not advice.
|
|
69
|
+
|
|
70
|
+
**Self-check before final emit.** If this turn contains an offer or a follow-up, render it via `ui_show`, not prose.
|
|
71
|
+
|
|
72
|
+
**Long turns show progress.** Any post-submit / post-skill-load turn must render a `task_progress` card within ~5s, or fall back to streaming text. Bind "long turn" → "task_progress emitted": a long-running turn that produces neither a progress card nor streaming text didn't satisfy this move.
|
|
73
|
+
|
|
74
|
+
**Action Trust-Guarantee.** Sibling to the OAuth Trust-Guarantee. Before a bulk write / delete / destructive op, render a `ui_show` preview — a table surface showing total count, breakdown, sample rows, and the categories to confirm. The user commits or refines on that surface; only then do you execute. Single-item actions use the natural draft instead. Threshold for the preview gate: bulk *and* low recoverability. One of the two alone doesn't trip it.
|
|
75
|
+
|
|
76
|
+
**Start Small.** On first execution of any skill, prefer the smallest meaningful result over the most complete result. Show, then offer to expand.
|
|
77
|
+
|
|
78
|
+
**Corrections route upstream.** When you identify a rail-level failure mid-conversation, the write target is THIS template (the upstream activation rail), not the per-conversation SOUL.md or BOOTSTRAP.md. Per-conversation files can't fix the rail; only the rail fixes the rail.
|
|
79
|
+
|
|
52
80
|
## What to defer
|
|
53
81
|
|
|
54
82
|
Identity writes (IDENTITY.md, SOUL.md), user-profile writes, journal entries: all wait until the rail produces real signal, which is Moment 1 output at the earliest. None of them delay a user-visible response. None of them happen alongside the opening turn.
|
|
@@ -57,6 +85,8 @@ The base BOOTSTRAP task_preferences fallback is not on this rail. Your opener is
|
|
|
57
85
|
|
|
58
86
|
## Wrap
|
|
59
87
|
|
|
88
|
+
<!-- Open question: the default wrap tone below is opinionated (brisk, dataset-first). Whether that's the right note to end the activation rail on is a conscious call we haven't made yet — flag, don't silently change. -->
|
|
89
|
+
|
|
60
90
|
When the user is clearly done with this conversation, write one journal entry: what they needed, which outcome they accepted, what follow-through they took. Update NOW.md. Delete this file.
|
|
61
91
|
|
|
62
92
|
The rail-completion shape in your journal is the dataset for v2 tuning. Which outcome they took at Propose, whether they bounced to "what else?", which follow-through they picked. Write it so the next iteration has signal to learn from.
|
|
@@ -73,11 +73,10 @@ function isDynamicPageAppSubstitute(input: Record<string, unknown>): boolean {
|
|
|
73
73
|
return isSubstantialInteractiveHtml(input);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
const INTERACTIVE_HTML_RE =
|
|
76
|
+
const INTERACTIVE_HTML_RE =
|
|
77
|
+
/<script\b|on[a-z]+\s*=|addEventListener|new Chart\b|window\.vellum\b/i;
|
|
77
78
|
|
|
78
|
-
function isSubstantialInteractiveHtml(
|
|
79
|
-
input: Record<string, unknown>,
|
|
80
|
-
): boolean {
|
|
79
|
+
function isSubstantialInteractiveHtml(input: Record<string, unknown>): boolean {
|
|
81
80
|
const data = asRecord(input.data);
|
|
82
81
|
const html = data?.html;
|
|
83
82
|
if (typeof html !== "string") {
|
|
@@ -137,7 +136,7 @@ export const uiShowTool = {
|
|
|
137
136
|
"- file_upload: { prompt, acceptedTypes?, maxFiles? }\n" +
|
|
138
137
|
"- task_preferences: {} (no data needed — categories are rendered client-side)\n" +
|
|
139
138
|
'- work_result: { eyebrow?, status?: "completed"|"partial"|"failed"|"in_progress", summary?, metrics?: [{ label, value, detail?, tone?: "neutral"|"positive"|"warning"|"negative" }], sections?: [{ id?, title, description?, type?: "items"|"timeline"|"diff"|"artifacts"|"warnings", items?: [{ id?, title, description?, status?, tone?, metadata?: [{ label, value }], href? }], diffs?: [{ label?, before?, after? }] }] }. Shows a structured receipt after real work: what changed, what was skipped, proof points, and next actions. Keep display-only unless explicit follow-up buttons are needed.\n\n' +
|
|
140
|
-
"
|
|
139
|
+
"Emit a task_progress card BEFORE your first tool call on any multi-step, long-running, or post-form turn (web searches, file operations, research), and keep its steps updated as work progresses. Skills that estimate >30s of work should declare progress upfront with concrete step labels (and counts when known).",
|
|
141
140
|
category: "ui-surface",
|
|
142
141
|
defaultRiskLevel: RiskLevel.Low,
|
|
143
142
|
executionTarget: "host",
|