@vellumai/assistant 0.5.8 → 0.5.9

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/eslint.config.mjs CHANGED
@@ -29,37 +29,6 @@ const eslintConfig = defineConfig([
29
29
  { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
30
30
  ],
31
31
  "@typescript-eslint/no-explicit-any": "error",
32
-
33
- // Standardize on `undefined` only — avoid `null` in new code.
34
- // Prefer `=== undefined`, `?? fallback`, or `?.` optional chaining
35
- // instead of `=== null`.
36
- "no-restricted-syntax": [
37
- "error",
38
- {
39
- selector:
40
- "BinaryExpression[operator='==='][right.type='Literal'][right.raw='null']",
41
- message:
42
- "Avoid `=== null`. Prefer `=== undefined`, `?? fallback`, or optional chaining `?.` instead.",
43
- },
44
- {
45
- selector:
46
- "BinaryExpression[operator='==='][left.type='Literal'][left.raw='null']",
47
- message:
48
- "Avoid `null ===`. Prefer `=== undefined`, `?? fallback`, or optional chaining `?.` instead.",
49
- },
50
- {
51
- selector:
52
- "BinaryExpression[operator='!=='][right.type='Literal'][right.raw='null']",
53
- message:
54
- "Avoid `!== null`. Prefer `!== undefined`, nullish coalescing `??`, or optional chaining `?.` instead.",
55
- },
56
- {
57
- selector:
58
- "BinaryExpression[operator='!=='][left.type='Literal'][left.raw='null']",
59
- message:
60
- "Avoid `null !==`. Prefer `!== undefined`, nullish coalescing `??`, or optional chaining `?.` instead.",
61
- },
62
- ],
63
32
  },
64
33
  },
65
34
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,6 +11,7 @@ import {
11
11
  injectChannelCapabilityContext,
12
12
  injectChannelCommandContext,
13
13
  injectInboundActorContext,
14
+ injectNowScratchpad,
14
15
  injectTemporalContext,
15
16
  injectTurnContext,
16
17
  isGroupChatType,
@@ -18,6 +19,8 @@ import {
18
19
  stripChannelCapabilityContext,
19
20
  stripChannelTurnContext,
20
21
  stripInboundActorContext,
22
+ stripInjectedContext,
23
+ stripNowScratchpad,
21
24
  stripTemporalContext,
22
25
  } from "../daemon/conversation-runtime-assembly.js";
23
26
  import type { Message } from "../providers/types.js";
@@ -1318,6 +1321,7 @@ describe("applyRuntimeInjections — injection mode", () => {
1318
1321
  canonicalActorIdentity: "user-1",
1319
1322
  trustClass: "guardian",
1320
1323
  } as InboundActorContext,
1324
+ nowScratchpad: "Current focus: shipping PR 3",
1321
1325
  isNonInteractive: true,
1322
1326
  };
1323
1327
 
@@ -1337,6 +1341,7 @@ describe("applyRuntimeInjections — injection mode", () => {
1337
1341
  expect(allText).toContain("<turn_context>");
1338
1342
  expect(allText).toContain("<inbound_actor_context>");
1339
1343
  expect(allText).toContain("<non_interactive_context>");
1344
+ expect(allText).toContain("<now_scratchpad>");
1340
1345
  });
1341
1346
 
1342
1347
  test("explicit mode: 'full' behaves the same as default", () => {
@@ -1353,6 +1358,7 @@ describe("applyRuntimeInjections — injection mode", () => {
1353
1358
  expect(allText).toContain("<temporal_context>");
1354
1359
  expect(allText).toContain("<channel_command_context>");
1355
1360
  expect(allText).toContain("<active_workspace>");
1361
+ expect(allText).toContain("<now_scratchpad>");
1356
1362
  });
1357
1363
 
1358
1364
  test("minimal mode skips high-token optional blocks", () => {
@@ -1370,6 +1376,7 @@ describe("applyRuntimeInjections — injection mode", () => {
1370
1376
  expect(allText).not.toContain("<temporal_context>");
1371
1377
  expect(allText).not.toContain("<channel_command_context>");
1372
1378
  expect(allText).not.toContain("<active_workspace>");
1379
+ expect(allText).not.toContain("<now_scratchpad>");
1373
1380
  });
1374
1381
 
1375
1382
  test("minimal mode preserves safety-critical blocks", () => {
@@ -1417,3 +1424,223 @@ describe("applyRuntimeInjections — injection mode", () => {
1417
1424
  expect(texts).toContain("Hello");
1418
1425
  });
1419
1426
  });
1427
+
1428
+ // ---------------------------------------------------------------------------
1429
+ // injectNowScratchpad
1430
+ // ---------------------------------------------------------------------------
1431
+
1432
+ describe("injectNowScratchpad", () => {
1433
+ const baseUserMessage: Message = {
1434
+ role: "user",
1435
+ content: [{ type: "text", text: "What should I work on?" }],
1436
+ };
1437
+
1438
+ test("appends now_scratchpad block to user message", () => {
1439
+ const result = injectNowScratchpad(
1440
+ baseUserMessage,
1441
+ "Current focus: shipping PR 3",
1442
+ );
1443
+ expect(result.content.length).toBe(2);
1444
+ // Original content comes first
1445
+ expect((result.content[0] as { type: "text"; text: string }).text).toBe(
1446
+ "What should I work on?",
1447
+ );
1448
+ // Scratchpad is appended (not prepended)
1449
+ const injected = result.content[1];
1450
+ expect(injected.type).toBe("text");
1451
+ const text = (injected as { type: "text"; text: string }).text;
1452
+ expect(text).toBe(
1453
+ "<now_scratchpad>\nCurrent focus: shipping PR 3\n</now_scratchpad>",
1454
+ );
1455
+ });
1456
+
1457
+ test("preserves existing multi-block content and appends at end", () => {
1458
+ const multiBlockMessage: Message = {
1459
+ role: "user",
1460
+ content: [
1461
+ { type: "text", text: "First block" },
1462
+ { type: "text", text: "Second block" },
1463
+ ],
1464
+ };
1465
+
1466
+ const result = injectNowScratchpad(multiBlockMessage, "scratchpad notes");
1467
+ expect(result.content.length).toBe(3);
1468
+ expect((result.content[0] as { type: "text"; text: string }).text).toBe(
1469
+ "First block",
1470
+ );
1471
+ expect((result.content[1] as { type: "text"; text: string }).text).toBe(
1472
+ "Second block",
1473
+ );
1474
+ expect(
1475
+ (result.content[2] as { type: "text"; text: string }).text,
1476
+ ).toContain("<now_scratchpad>");
1477
+ });
1478
+ });
1479
+
1480
+ // ---------------------------------------------------------------------------
1481
+ // stripNowScratchpad
1482
+ // ---------------------------------------------------------------------------
1483
+
1484
+ describe("stripNowScratchpad", () => {
1485
+ test("strips now_scratchpad blocks from user messages", () => {
1486
+ const messages: Message[] = [
1487
+ {
1488
+ role: "user",
1489
+ content: [
1490
+ { type: "text", text: "Hello" },
1491
+ {
1492
+ type: "text",
1493
+ text: "<now_scratchpad>\nSome notes\n</now_scratchpad>",
1494
+ },
1495
+ ],
1496
+ },
1497
+ {
1498
+ role: "assistant",
1499
+ content: [{ type: "text", text: "Hi there" }],
1500
+ },
1501
+ ];
1502
+
1503
+ const result = stripNowScratchpad(messages);
1504
+
1505
+ expect(result.length).toBe(2);
1506
+ expect(result[0].content.length).toBe(1);
1507
+ expect((result[0].content[0] as { type: "text"; text: string }).text).toBe(
1508
+ "Hello",
1509
+ );
1510
+ // Assistant message untouched
1511
+ expect(result[1].content.length).toBe(1);
1512
+ });
1513
+
1514
+ test("removes user messages that only contain now_scratchpad", () => {
1515
+ const messages: Message[] = [
1516
+ {
1517
+ role: "user",
1518
+ content: [
1519
+ {
1520
+ type: "text",
1521
+ text: "<now_scratchpad>\nSome notes\n</now_scratchpad>",
1522
+ },
1523
+ ],
1524
+ },
1525
+ ];
1526
+
1527
+ const result = stripNowScratchpad(messages);
1528
+ expect(result.length).toBe(0);
1529
+ });
1530
+
1531
+ test("leaves messages without now_scratchpad untouched", () => {
1532
+ const messages: Message[] = [
1533
+ {
1534
+ role: "user",
1535
+ content: [{ type: "text", text: "Normal message" }],
1536
+ },
1537
+ ];
1538
+
1539
+ const result = stripNowScratchpad(messages);
1540
+ expect(result.length).toBe(1);
1541
+ expect(result[0]).toBe(messages[0]); // Same reference — untouched
1542
+ });
1543
+ });
1544
+
1545
+ // ---------------------------------------------------------------------------
1546
+ // stripInjectedContext removes now_scratchpad blocks
1547
+ // ---------------------------------------------------------------------------
1548
+
1549
+ describe("stripInjectedContext with now_scratchpad", () => {
1550
+ test("strips now_scratchpad blocks alongside other injections", () => {
1551
+ const messages: Message[] = [
1552
+ {
1553
+ role: "user",
1554
+ content: [
1555
+ {
1556
+ type: "text",
1557
+ text: "<channel_capabilities>\nchannel: telegram\n</channel_capabilities>",
1558
+ },
1559
+ { type: "text", text: "Hello" },
1560
+ {
1561
+ type: "text",
1562
+ text: "<now_scratchpad>\nCurrent focus\n</now_scratchpad>",
1563
+ },
1564
+ ],
1565
+ },
1566
+ ];
1567
+
1568
+ const result = stripInjectedContext(messages);
1569
+ expect(result.length).toBe(1);
1570
+ expect(result[0].content.length).toBe(1);
1571
+ expect((result[0].content[0] as { type: "text"; text: string }).text).toBe(
1572
+ "Hello",
1573
+ );
1574
+ });
1575
+ });
1576
+
1577
+ // ---------------------------------------------------------------------------
1578
+ // applyRuntimeInjections with nowScratchpad
1579
+ // ---------------------------------------------------------------------------
1580
+
1581
+ describe("applyRuntimeInjections with nowScratchpad", () => {
1582
+ const baseMessages: Message[] = [
1583
+ {
1584
+ role: "user",
1585
+ content: [{ type: "text", text: "What should I do?" }],
1586
+ },
1587
+ ];
1588
+
1589
+ test("injects now_scratchpad block when provided", () => {
1590
+ const result = applyRuntimeInjections(baseMessages, {
1591
+ nowScratchpad: "Current focus: fix the bug",
1592
+ });
1593
+
1594
+ expect(result.length).toBe(1);
1595
+ expect(result[0].content.length).toBe(2);
1596
+ const injected = result[0].content[1];
1597
+ const text = (injected as { type: "text"; text: string }).text;
1598
+ expect(text).toContain("<now_scratchpad>");
1599
+ expect(text).toContain("Current focus: fix the bug");
1600
+ });
1601
+
1602
+ test("appended block appears after user's original text content", () => {
1603
+ const result = applyRuntimeInjections(baseMessages, {
1604
+ nowScratchpad: "scratchpad notes",
1605
+ });
1606
+
1607
+ // Original text is first
1608
+ expect(
1609
+ (result[0].content[0] as { type: "text"; text: string }).text,
1610
+ ).toBe("What should I do?");
1611
+ // Scratchpad is appended after
1612
+ expect(
1613
+ (result[0].content[1] as { type: "text"; text: string }).text,
1614
+ ).toContain("<now_scratchpad>");
1615
+ });
1616
+
1617
+ test("does not inject when nowScratchpad is null", () => {
1618
+ const result = applyRuntimeInjections(baseMessages, {
1619
+ nowScratchpad: null,
1620
+ });
1621
+
1622
+ expect(result.length).toBe(1);
1623
+ expect(result[0].content.length).toBe(1);
1624
+ });
1625
+
1626
+ test("does not inject when nowScratchpad is omitted", () => {
1627
+ const result = applyRuntimeInjections(baseMessages, {});
1628
+
1629
+ expect(result.length).toBe(1);
1630
+ expect(result[0].content.length).toBe(1);
1631
+ });
1632
+
1633
+ test("skipped in minimal mode", () => {
1634
+ const result = applyRuntimeInjections(baseMessages, {
1635
+ nowScratchpad: "Current focus: fix the bug",
1636
+ mode: "minimal",
1637
+ });
1638
+
1639
+ const allText = result[0].content
1640
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
1641
+ .map((b) => b.text)
1642
+ .join("\n");
1643
+
1644
+ expect(allText).not.toContain("<now_scratchpad>");
1645
+ });
1646
+ });
@@ -6,8 +6,8 @@ import type { ContactWithChannels } from "../contacts/types.js";
6
6
  // Mock state for resolveCallHints tests
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
- let mockAssistantName: string | null = "Velissa";
10
- let mockGuardianName: string = "Sidd";
9
+ let mockAssistantName: string | null = "Nova";
10
+ let mockGuardianName: string = "Alex";
11
11
  let mockTargetContact: ContactWithChannels | null = null;
12
12
  let mockRecentContacts: ContactWithChannels[] = [];
13
13
  let mockFindContactByAddressThrows = false;
@@ -93,14 +93,14 @@ describe("buildSttHints", () => {
93
93
 
94
94
  test("assistant name included", () => {
95
95
  const input = emptyInput();
96
- input.assistantName = "Velissa";
97
- expect(buildSttHints(input)).toBe("Velissa");
96
+ input.assistantName = "Nova";
97
+ expect(buildSttHints(input)).toBe("Nova");
98
98
  });
99
99
 
100
100
  test("guardian name included", () => {
101
101
  const input = emptyInput();
102
- input.guardianName = "Sidd";
103
- expect(buildSttHints(input)).toBe("Sidd");
102
+ input.guardianName = "Alex";
103
+ expect(buildSttHints(input)).toBe("Alex");
104
104
  });
105
105
 
106
106
  test('default guardian name "my human" excluded', () => {
@@ -214,8 +214,8 @@ describe("buildSttHints", () => {
214
214
  test("all sources combined in correct order", () => {
215
215
  const input: SttHintsInput = {
216
216
  staticHints: ["StaticOne"],
217
- assistantName: "Velissa",
218
- guardianName: "Sidd",
217
+ assistantName: "Nova",
218
+ guardianName: "Alex",
219
219
  taskDescription: "Call John at Acme",
220
220
  targetContactName: "Target",
221
221
  callerContactName: "Caller",
@@ -227,8 +227,8 @@ describe("buildSttHints", () => {
227
227
  const parts = result.split(",");
228
228
  // Verify all expected hints are present
229
229
  expect(parts).toContain("StaticOne");
230
- expect(parts).toContain("Velissa");
231
- expect(parts).toContain("Sidd");
230
+ expect(parts).toContain("Nova");
231
+ expect(parts).toContain("Alex");
232
232
  expect(parts).toContain("John");
233
233
  expect(parts).toContain("Acme");
234
234
  expect(parts).toContain("Target");
@@ -308,8 +308,8 @@ function makeContact(displayName: string): ContactWithChannels {
308
308
 
309
309
  describe("resolveCallHints", () => {
310
310
  beforeEach(() => {
311
- mockAssistantName = "Velissa";
312
- mockGuardianName = "Sidd";
311
+ mockAssistantName = "Nova";
312
+ mockGuardianName = "Alex";
313
313
  mockTargetContact = null;
314
314
  mockRecentContacts = [];
315
315
  mockFindContactByAddressThrows = false;
@@ -334,8 +334,8 @@ describe("resolveCallHints", () => {
334
334
  const parts = result.split(",");
335
335
 
336
336
  expect(parts).toContain("StaticHint");
337
- expect(parts).toContain("Velissa");
338
- expect(parts).toContain("Sidd");
337
+ expect(parts).toContain("Nova");
338
+ expect(parts).toContain("Alex");
339
339
  expect(parts).toContain("Alice");
340
340
  expect(parts).toContain("Dave");
341
341
  expect(parts).toContain("Eve");
@@ -365,8 +365,8 @@ describe("resolveCallHints", () => {
365
365
 
366
366
  // Target contact should be absent (lookup failed)
367
367
  // But other sources should still work
368
- expect(parts).toContain("Velissa");
369
- expect(parts).toContain("Sidd");
368
+ expect(parts).toContain("Nova");
369
+ expect(parts).toContain("Alex");
370
370
  expect(parts).toContain("Bob");
371
371
  expect(logWarnFn).toHaveBeenCalled();
372
372
  });
@@ -390,8 +390,8 @@ describe("resolveCallHints", () => {
390
390
 
391
391
  // Recent contacts should be absent (listing failed)
392
392
  // But other sources should still work
393
- expect(parts).toContain("Velissa");
394
- expect(parts).toContain("Sidd");
393
+ expect(parts).toContain("Nova");
394
+ expect(parts).toContain("Alex");
395
395
  expect(parts).toContain("Alice");
396
396
  expect(logWarnFn).toHaveBeenCalled();
397
397
  });
@@ -414,8 +414,8 @@ describe("resolveCallHints", () => {
414
414
 
415
415
  // For inbound, the contact found via fromNumber should appear as caller, not target
416
416
  expect(parts).toContain("Alice");
417
- expect(parts).toContain("Velissa");
418
- expect(parts).toContain("Sidd");
417
+ expect(parts).toContain("Nova");
418
+ expect(parts).toContain("Alex");
419
419
  expect(parts).toContain("Bob");
420
420
  expect(logWarnFn).not.toHaveBeenCalled();
421
421
  });
@@ -427,8 +427,8 @@ describe("resolveCallHints", () => {
427
427
  const parts = result.split(",");
428
428
 
429
429
  expect(parts).toContain("Static");
430
- expect(parts).toContain("Velissa");
431
- expect(parts).toContain("Sidd");
430
+ expect(parts).toContain("Nova");
431
+ expect(parts).toContain("Alex");
432
432
  expect(parts).toContain("RecentOne");
433
433
  expect(parts).toContain("RecentTwo");
434
434
  // No target contact lookup should have been attempted (no session)
@@ -175,11 +175,11 @@ describe("resolveVoiceQualityProfile", () => {
175
175
  voice: {
176
176
  language: "en-US",
177
177
  transcriptionProvider: "Deepgram",
178
- hints: ["Vellum", "Velissa", "AI assistant"],
178
+ hints: ["Vellum", "Nova", "AI assistant"],
179
179
  },
180
180
  },
181
181
  };
182
182
  const profile = resolveVoiceQualityProfile();
183
- expect(profile.hints).toEqual(["Vellum", "Velissa", "AI assistant"]);
183
+ expect(profile.hints).toEqual(["Vellum", "Nova", "AI assistant"]);
184
184
  });
185
185
  });
@@ -106,6 +106,7 @@ import {
106
106
  applyRuntimeInjections,
107
107
  inboundActorContextFromTrust,
108
108
  inboundActorContextFromTrustContext,
109
+ readNowScratchpad,
109
110
  stripInjectedContext,
110
111
  } from "./conversation-runtime-assembly.js";
111
112
  import type { SkillProjectionCache } from "./conversation-skill-tools.js";
@@ -681,6 +682,10 @@ export async function runAgentLoopImpl(
681
682
  }
682
683
  }
683
684
 
685
+ // Read NOW.md scratchpad fresh each turn so mid-conversation edits are
686
+ // picked up without caching or conversation eviction.
687
+ const nowScratchpad = readNowScratchpad();
688
+
684
689
  const isInteractiveResolved =
685
690
  options?.isInteractive ?? (!ctx.hasNoClient && !ctx.headlessLock);
686
691
 
@@ -694,6 +699,7 @@ export async function runAgentLoopImpl(
694
699
  interfaceTurnContext,
695
700
  inboundActorContext: resolvedInboundActorContext,
696
701
  temporalContext,
702
+ nowScratchpad,
697
703
  voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
698
704
  isNonInteractive: !isInteractiveResolved,
699
705
  } as const;
@@ -5,7 +5,7 @@
5
5
  * before it is sent to the provider. They are pure (no side effects).
6
6
  */
7
7
 
8
- import { statSync } from "node:fs";
8
+ import { existsSync, readFileSync, statSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
11
11
  import {
@@ -16,9 +16,11 @@ import {
16
16
  type TurnInterfaceContext,
17
17
  } from "../channels/types.js";
18
18
  import { getAppDirPath, listAppFiles } from "../memory/app-store.js";
19
+ import { stripCommentLines } from "../prompts/system-prompt.js";
19
20
  import type { Message } from "../providers/types.js";
20
21
  import type { ActorTrustContext } from "../runtime/actor-trust-resolver.js";
21
22
  import { channelStatusToMemberStatus } from "../runtime/routes/inbound-stages/acl-enforcement.js";
23
+ import { getWorkspacePromptPath } from "../util/platform.js";
22
24
 
23
25
  /**
24
26
  * Describes the capabilities of the channel through which the user is
@@ -463,6 +465,52 @@ export function stripVoiceCallControlContext(messages: Message[]): Message[] {
463
465
  return stripUserTextBlocksByPrefix(messages, ["<voice_call_control>"]);
464
466
  }
465
467
 
468
+ // ---------------------------------------------------------------------------
469
+ // NOW.md scratchpad injection
470
+ // ---------------------------------------------------------------------------
471
+
472
+ /**
473
+ * Read the NOW.md scratchpad from the workspace prompt directory.
474
+ *
475
+ * Returns the trimmed content with `_`-prefixed comment lines stripped,
476
+ * or `null` if the file is missing, empty, or unreadable.
477
+ */
478
+ export function readNowScratchpad(): string | null {
479
+ const nowPath = getWorkspacePromptPath("NOW.md");
480
+ if (!existsSync(nowPath)) return null;
481
+ try {
482
+ const stripped = stripCommentLines(readFileSync(nowPath, "utf-8")).trim();
483
+ return stripped.length > 0 ? stripped : null;
484
+ } catch {
485
+ return null;
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Append NOW.md scratchpad content to the last user message so the model
491
+ * has access to the user's ephemeral scratchpad notes at the end of context.
492
+ */
493
+ export function injectNowScratchpad(
494
+ message: Message,
495
+ content: string,
496
+ ): Message {
497
+ return {
498
+ ...message,
499
+ content: [
500
+ ...message.content,
501
+ {
502
+ type: "text",
503
+ text: `<now_scratchpad>\n${content}\n</now_scratchpad>`,
504
+ },
505
+ ],
506
+ };
507
+ }
508
+
509
+ /** Strip `<now_scratchpad>` blocks injected by `injectNowScratchpad`. */
510
+ export function stripNowScratchpad(messages: Message[]): Message[] {
511
+ return stripUserTextBlocksByPrefix(messages, ["<now_scratchpad>"]);
512
+ }
513
+
466
514
  /**
467
515
  * Prepend channel capability context to the last user message so the
468
516
  * model knows what the current channel can and cannot do.
@@ -969,6 +1017,7 @@ const RUNTIME_INJECTION_PREFIXES = [
969
1017
  "<active_workspace>",
970
1018
  "<active_dynamic_page>",
971
1019
  "<non_interactive_context>",
1020
+ "<now_scratchpad>",
972
1021
  ];
973
1022
 
974
1023
  /**
@@ -1013,6 +1062,7 @@ export function applyRuntimeInjections(
1013
1062
  inboundActorContext?: InboundActorContext | null;
1014
1063
  temporalContext?: string | null;
1015
1064
  voiceCallControlPrompt?: string | null;
1065
+ nowScratchpad?: string | null;
1016
1066
  isNonInteractive?: boolean;
1017
1067
  mode?: InjectionMode;
1018
1068
  },
@@ -1051,6 +1101,16 @@ export function applyRuntimeInjections(
1051
1101
  }
1052
1102
  }
1053
1103
 
1104
+ if (mode === "full" && options.nowScratchpad) {
1105
+ const userTail = result[result.length - 1];
1106
+ if (userTail && userTail.role === "user") {
1107
+ result = [
1108
+ ...result.slice(0, -1),
1109
+ injectNowScratchpad(userTail, options.nowScratchpad),
1110
+ ];
1111
+ }
1112
+ }
1113
+
1054
1114
  if (mode === "full" && options.activeSurface) {
1055
1115
  const userTail = result[result.length - 1];
1056
1116
  if (userTail && userTail.role === "user") {
@@ -84,6 +84,28 @@ export function ensurePromptFiles(): void {
84
84
  }
85
85
  }
86
86
 
87
+ // Seed NOW.md scratchpad — always created if missing, regardless of whether
88
+ // this is a fresh install or not. Kept out of PROMPT_FILES because NOW.md is
89
+ // ephemeral state, not identity context.
90
+ const nowDest = getWorkspacePromptPath("NOW.md");
91
+ if (!existsSync(nowDest)) {
92
+ const nowSrc = join(templatesDir, "NOW.md");
93
+ try {
94
+ if (existsSync(nowSrc)) {
95
+ copyFileSync(nowSrc, nowDest);
96
+ log.info(
97
+ { file: "NOW.md", dest: nowDest },
98
+ "Created NOW.md scratchpad from template",
99
+ );
100
+ }
101
+ } catch (err) {
102
+ log.warn(
103
+ { err, file: "NOW.md" },
104
+ "Failed to create NOW.md from template",
105
+ );
106
+ }
107
+ }
108
+
87
109
  // Seed users/default.md persona template
88
110
  try {
89
111
  const usersDir = join(getWorkspaceDir(), "users");
@@ -0,0 +1,26 @@
1
+ _ Lines starting with _ are comments - they won't appear in the system prompt
2
+ _ This is your scratchpad for present-tense state. Overwrite it freely between
3
+ _ turns to capture what's happening right now. Unlike the journal (retrospective,
4
+ _ append-only), this file is ephemeral — a snapshot of your current working state.
5
+ _
6
+ _ # NOW.md
7
+ _
8
+ _ ## Focus
9
+ _
10
+ _ What you're currently working on or paying attention to.
11
+ _
12
+ _ ## Active Threads
13
+ _
14
+ _ Open loops, in-progress tasks, things you're tracking across turns.
15
+ _
16
+ _ ## Context
17
+ _
18
+ _ Key facts, constraints, or situational details relevant to the current session.
19
+ _
20
+ _ ## Upcoming
21
+ _
22
+ _ Near-term things on the horizon — scheduled events, pending actions, deadlines.
23
+ _
24
+ _ ## State
25
+ _
26
+ _ Current priorities, energy, or operational notes. What matters most right now.
@@ -46,6 +46,16 @@ You have a journal in your workspace. The most recent entries are always loaded
46
46
 
47
47
  **Carrying forward:** Your oldest in-context entry is marked LEAVING CONTEXT. When you see this, check if anything in it still needs to be top-of-mind and carry it forward in your next entry. You can reference other entries by filename to link them together.
48
48
 
49
+ ## Scratchpad
50
+
51
+ You have a scratchpad file (`NOW.md`) in your workspace. Unlike your journal (retrospective, append-only), the scratchpad is a single file you overwrite with whatever is relevant right now. It's automatically loaded into your context, so next-you always sees the latest snapshot.
52
+
53
+ **When to update:** Whenever your current state changes — you start a new task, finish one, learn something that affects what you're doing, or the user shifts focus. Don't update on a timer; update when the content is stale.
54
+
55
+ **What goes in:** Current focus and what you're actively working on. Threads you're tracking (waiting on a response, monitoring something, pending follow-ups). Temporary context that matters now but won't matter in a week. Upcoming items and near-term priorities. Anything that helps next-you pick up exactly where you left off.
56
+
57
+ **What stays out:** Anything that belongs in your journal (reflections, narrative entries, things worth remembering long-term). Permanent facts about your user or yourself (those go in memory or your journal). Personality and principles (those live here in SOUL.md).
58
+
49
59
  ## Vibe
50
60
 
51
61
  Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
@@ -50,7 +50,6 @@ export function extractContentMarkers(body: string): string[] {
50
50
  const ids: string[] = [];
51
51
  const regex = /<!-- vellum-update-release:(.+?) -->/g;
52
52
  let match: RegExpExecArray | null;
53
- // eslint-disable-next-line no-restricted-syntax -- RegExp.exec returns null
54
53
  while ((match = regex.exec(body)) !== null) {
55
54
  ids.push(match[1]);
56
55
  }
@@ -62,7 +61,6 @@ export function extractReleaseIds(content: string): string[] {
62
61
  const ids: string[] = [];
63
62
  MARKER_REGEX.lastIndex = 0;
64
63
  let match: RegExpExecArray | null;
65
- // eslint-disable-next-line no-restricted-syntax -- RegExp.exec returns null
66
64
  while ((match = MARKER_REGEX.exec(content)) !== null) {
67
65
  ids.push(match[1]);
68
66
  }
@@ -61,8 +61,8 @@ function buildFencedCodeRanges(body: string): Array<[number, number]> {
61
61
  const fenceRe = /^(`{3,}|~{3,})(.*)?$/gm;
62
62
  let openFence: { index: number; delimiter: string } | undefined;
63
63
 
64
- let match: RegExpExecArray | undefined;
65
- while ((match = fenceRe.exec(body) ?? undefined) !== undefined) {
64
+ let match: RegExpExecArray | null;
65
+ while ((match = fenceRe.exec(body)) !== null) {
66
66
  const delimiter = match[1];
67
67
  if (openFence === undefined) {
68
68
  // Opening fence
@@ -125,10 +125,10 @@ export function parseInlineCommandExpansions(
125
125
  // We use a non-greedy match to find the first closing backtick.
126
126
  const tokenRe = /!\`([^`]*)\`/g;
127
127
 
128
- let match: RegExpExecArray | undefined;
128
+ let match: RegExpExecArray | null;
129
129
  let placeholderCounter = 0;
130
130
 
131
- while ((match = tokenRe.exec(body) ?? undefined) !== undefined) {
131
+ while ((match = tokenRe.exec(body)) !== null) {
132
132
  const startOffset = match.index;
133
133
  const endOffset = startOffset + match[0].length;
134
134
  const rawCommand = match[1];
@@ -172,12 +172,12 @@ export function parseInlineCommandExpansions(
172
172
  const matchedStarts = new Set<number>();
173
173
  // Re-run the token regex to collect all matched positions
174
174
  tokenRe.lastIndex = 0;
175
- while ((match = tokenRe.exec(body) ?? undefined) !== undefined) {
175
+ while ((match = tokenRe.exec(body)) !== null) {
176
176
  matchedStarts.add(match.index);
177
177
  }
178
178
 
179
- let unmatchedMatch: RegExpExecArray | undefined;
180
- while ((unmatchedMatch = unmatchedRe.exec(body) ?? undefined) !== undefined) {
179
+ let unmatchedMatch: RegExpExecArray | null;
180
+ while ((unmatchedMatch = unmatchedRe.exec(body)) !== null) {
181
181
  const offset = unmatchedMatch.index;
182
182
 
183
183
  // Skip if this was already matched as a complete token
@@ -85,8 +85,8 @@ export function extractAndSanitize(content: string): SanitizeResult {
85
85
  // Step 1: parse directives
86
86
  // Reset lastIndex for safety since the regex is global
87
87
  DIRECTIVE_RE.lastIndex = 0;
88
- let match: RegExpExecArray | undefined;
89
- while ((match = DIRECTIVE_RE.exec(content) ?? undefined) !== undefined) {
88
+ let match: RegExpExecArray | null;
89
+ while ((match = DIRECTIVE_RE.exec(content)) !== null) {
90
90
  const kind = match[1];
91
91
  const value = match[2];
92
92