@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 +0 -31
- package/package.json +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +227 -0
- package/src/__tests__/stt-hints.test.ts +22 -22
- package/src/__tests__/voice-quality.test.ts +2 -2
- package/src/daemon/conversation-agent-loop.ts +6 -0
- package/src/daemon/conversation-runtime-assembly.ts +61 -1
- package/src/prompts/system-prompt.ts +22 -0
- package/src/prompts/templates/NOW.md +26 -0
- package/src/prompts/templates/SOUL.md +10 -0
- package/src/prompts/update-bulletin-format.ts +0 -2
- package/src/skills/inline-command-expansions.ts +7 -7
- package/src/tools/sensitive-output-placeholders.ts +2 -2
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
|
@@ -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 = "
|
|
10
|
-
let mockGuardianName: string = "
|
|
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 = "
|
|
97
|
-
expect(buildSttHints(input)).toBe("
|
|
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 = "
|
|
103
|
-
expect(buildSttHints(input)).toBe("
|
|
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: "
|
|
218
|
-
guardianName: "
|
|
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("
|
|
231
|
-
expect(parts).toContain("
|
|
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 = "
|
|
312
|
-
mockGuardianName = "
|
|
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("
|
|
338
|
-
expect(parts).toContain("
|
|
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("
|
|
369
|
-
expect(parts).toContain("
|
|
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("
|
|
394
|
-
expect(parts).toContain("
|
|
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("
|
|
418
|
-
expect(parts).toContain("
|
|
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("
|
|
431
|
-
expect(parts).toContain("
|
|
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", "
|
|
178
|
+
hints: ["Vellum", "Nova", "AI assistant"],
|
|
179
179
|
},
|
|
180
180
|
},
|
|
181
181
|
};
|
|
182
182
|
const profile = resolveVoiceQualityProfile();
|
|
183
|
-
expect(profile.hints).toEqual(["Vellum", "
|
|
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 |
|
|
65
|
-
while ((match = fenceRe.exec(body)
|
|
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 |
|
|
128
|
+
let match: RegExpExecArray | null;
|
|
129
129
|
let placeholderCounter = 0;
|
|
130
130
|
|
|
131
|
-
while ((match = tokenRe.exec(body)
|
|
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)
|
|
175
|
+
while ((match = tokenRe.exec(body)) !== null) {
|
|
176
176
|
matchedStarts.add(match.index);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
let unmatchedMatch: RegExpExecArray |
|
|
180
|
-
while ((unmatchedMatch = unmatchedRe.exec(body)
|
|
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 |
|
|
89
|
-
while ((match = DIRECTIVE_RE.exec(content)
|
|
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
|
|