@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.8-dev.202606052332.17fc8ea",
3
+ "version": "0.8.8-dev.202606060901.61e1660",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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("skips (re)injection when a legacy <memory>-wrapped static block is present", async () => {
144
- // Rows persisted before the `<info>` switch rehydrate the static block as
145
- // `<memory>…</memory>`. Re-injecting a fresh `<info>` copy alongside it
146
- // would duplicate the content until the next compaction.
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## Essentials\n\nAlice prefers VS Code.\n</memory>",
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
- expect(await memoryV2StaticInjector.produce(ctx, runMessages)).toBeNull();
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 ${providerLabel}: ${accountLabel}`
2034
- : `Connected ${providerLabel}`;
2046
+ ? `Connected ${label}: ${accountLabel}`
2047
+ : `Connected ${label}`;
2035
2048
  }
2036
2049
  if (actionId === "cancel" || data?.status === "cancelled") {
2037
- return `Cancelled ${providerLabel} connection`;
2050
+ return `Cancelled ${label} connection`;
2038
2051
  }
2039
2052
  if (data?.status === "error") {
2040
- return `${providerLabel} connection failed`;
2053
+ return `${label} connection failed`;
2041
2054
  }
2042
- return `${providerLabel} connection ${actionId}`;
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 ${providerLabel}: ${accountLabel}`
2128
- : `Connected ${providerLabel}`;
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 `${providerLabel} connection failed`;
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
- * The static block is wrapped in `<info>…</info>` today, but rows persisted
437
- * before that switch rehydrate verbatim as `<memory>…</memory>` (see
438
- * `conversation-lifecycle`), so the legacy wrapper counts as present too.
439
- * Matching `<memory>` cannot wrongly skip a needed injection: the static block
440
- * is only (re)injected on the first turn (empty history) and right after
441
- * compaction (which strips both wrappers), and on neither is a `<memory>` block
442
- * present — the dynamic activation `<memory>` block only survives on normal
443
- * cached turns, which is exactly when this injector must skip anyway.
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. Goals, not steps.
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 = /<script\b|on[a-z]+\s*=|addEventListener|new Chart\b|window\.vellum\b/i;
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
- "Proactively show a task_progress card before multi-step or long-running work (web searches, file operations, research). Show it before your first tool call, then update steps as work progresses.",
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",