@vellumai/assistant 0.12.2-dev.202609182015.188de8a → 0.12.2-dev.202609182114.335fa56
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/ARCHITECTURE.md +8 -6
- package/docs/architecture/turn-actor.md +2 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-event-targeting.test.ts +186 -0
- package/src/__tests__/assistant-stream-state.test.ts +7 -9
- package/src/__tests__/channel-retry-sweep.test.ts +9 -0
- package/src/__tests__/dm-backfill.test.ts +83 -1
- package/src/__tests__/helpers/verification-sessions-ipc-sim.ts +5 -0
- package/src/__tests__/message-provenance-contact-id.test.ts +69 -0
- package/src/__tests__/process-message-display-content.test.ts +56 -0
- package/src/__tests__/reaction-persistence.test.ts +5 -0
- package/src/__tests__/runtime-events-sse-reconnect.test.ts +1 -1
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +73 -0
- package/src/__tests__/thread-backfill.test.ts +53 -0
- package/src/approvals/AGENTS.md +1 -1
- package/src/cli/commands/roadmap.help.ts +2 -3
- package/src/daemon/conversation-messaging.ts +19 -1
- package/src/daemon/message-provenance.ts +23 -0
- package/src/daemon/process-message.ts +13 -4
- package/src/persistence/conversation-crud.ts +8 -0
- package/src/plugins/defaults/memory/__tests__/archive-removal-framing.test.ts +64 -0
- package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +2 -2
- package/src/plugins/defaults/memory/tools.test.ts +24 -1
- package/src/plugins/defaults/memory/tools.ts +12 -1
- package/src/runtime/assistant-event-hub.ts +16 -86
- package/src/runtime/assistant-event-targeting.ts +79 -0
- package/src/runtime/assistant-stream-state.ts +13 -112
- package/src/runtime/channel-retry-sweep.ts +1 -0
- package/src/runtime/http-types.ts +6 -0
- package/src/runtime/routes/events-routes.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +35 -38
- package/src/runtime/routes/inbound-stages/backfill-sender-provenance.test.ts +165 -0
- package/src/runtime/routes/inbound-stages/backfill-sender-provenance.ts +92 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +29 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -0
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +2 -0
- package/src/util/canonicalize-identity.test.ts +21 -0
- package/src/util/canonicalize-identity.ts +16 -0
|
@@ -73,6 +73,7 @@ import type {
|
|
|
73
73
|
import type { MessagingConversationContext } from "../daemon/conversation-messaging.js";
|
|
74
74
|
import { persistQueuedMessageBody } from "../daemon/conversation-messaging.js";
|
|
75
75
|
import type { MessageQueue } from "../daemon/conversation-queue-manager.js";
|
|
76
|
+
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
76
77
|
|
|
77
78
|
function createContext(): MessagingConversationContext {
|
|
78
79
|
const channel: TurnChannelContext = {
|
|
@@ -223,3 +224,75 @@ describe("scripted-turn metadata persistence", () => {
|
|
|
223
224
|
expect(lastUserMetadata().scripted).toBe(false);
|
|
224
225
|
});
|
|
225
226
|
});
|
|
227
|
+
|
|
228
|
+
describe("author contact id on a persisted user message", () => {
|
|
229
|
+
const sender: TrustContext = {
|
|
230
|
+
sourceChannel: "slack",
|
|
231
|
+
trustClass: "trusted_contact",
|
|
232
|
+
requesterContactId: "contact-alice",
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
beforeEach(() => {
|
|
236
|
+
addMessageCalls.length = 0;
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("a message relayed from the sender names them as its author", async () => {
|
|
240
|
+
await persistQueuedMessageBody(createContext(), {
|
|
241
|
+
content: "the export endpoint needs a scoped token",
|
|
242
|
+
requestId: "req-author-typed",
|
|
243
|
+
trustContext: sender,
|
|
244
|
+
author: sender,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(lastUserMetadata().provenanceContactId).toBe("contact-alice");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("a row persisted under the sender's trust but not relayed from them names no author", async () => {
|
|
251
|
+
// Machine-authored rows (ACP and subagent notifications, pointer turns)
|
|
252
|
+
// share this writer and the conversation's trust; only an explicit
|
|
253
|
+
// author names one.
|
|
254
|
+
await persistQueuedMessageBody(createContext(), {
|
|
255
|
+
content: "the delegated task finished",
|
|
256
|
+
requestId: "req-author-machine",
|
|
257
|
+
trustContext: sender,
|
|
258
|
+
metadata: { acpNotification: { acpSessionId: "acp-session-1" } },
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(lastUserMetadata().provenanceContactId).toBeUndefined();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test.each([
|
|
265
|
+
["scripted", { scripted: true }],
|
|
266
|
+
["automated", { metadata: { automated: true } }],
|
|
267
|
+
["hidden", { metadata: { hidden: true } }],
|
|
268
|
+
[
|
|
269
|
+
"ACP notification",
|
|
270
|
+
{ metadata: { acpNotification: { acpSessionId: "acp-session-1" } } },
|
|
271
|
+
],
|
|
272
|
+
[
|
|
273
|
+
"subagent notification",
|
|
274
|
+
{
|
|
275
|
+
metadata: {
|
|
276
|
+
subagentNotification: {
|
|
277
|
+
subagentId: "subagent-1",
|
|
278
|
+
label: "Research",
|
|
279
|
+
status: "completed",
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
] as const)(
|
|
285
|
+
"a %s row speaks in their voice without naming them",
|
|
286
|
+
async (kind, extra) => {
|
|
287
|
+
await persistQueuedMessageBody(createContext(), {
|
|
288
|
+
content: "sent on the sender's behalf",
|
|
289
|
+
requestId: `req-author-${kind}`,
|
|
290
|
+
trustContext: sender,
|
|
291
|
+
author: sender,
|
|
292
|
+
...extra,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(lastUserMetadata().provenanceContactId).toBeUndefined();
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
});
|
|
@@ -90,6 +90,7 @@ mock.module("../messaging/providers/slack/adapter.js", () => ({
|
|
|
90
90
|
|
|
91
91
|
import { v4 as uuid } from "uuid";
|
|
92
92
|
|
|
93
|
+
import * as inboundTrustReader from "../calls/inbound-trust-reader.js";
|
|
93
94
|
import {
|
|
94
95
|
loadRawConfig,
|
|
95
96
|
saveRawConfig,
|
|
@@ -142,10 +143,15 @@ installDefaultThreadPageMock();
|
|
|
142
143
|
backfillThreadMock.mockResolvedValue([]);
|
|
143
144
|
const backfillDmMock = spyOn(slackBackfill, "backfillDm");
|
|
144
145
|
backfillDmMock.mockResolvedValue([]);
|
|
146
|
+
// Backfilled senders are classified by the gateway verdict. With no gateway in
|
|
147
|
+
// the test process the real read reports unreachable, so tests that need a
|
|
148
|
+
// verdict stub it per call.
|
|
149
|
+
const readInboundTrustSpy = spyOn(inboundTrustReader, "readInboundTrust");
|
|
145
150
|
|
|
146
151
|
afterAll(() => {
|
|
147
152
|
backfillThreadPageMock.mockRestore();
|
|
148
153
|
backfillDmMock.mockRestore();
|
|
154
|
+
readInboundTrustSpy.mockRestore();
|
|
149
155
|
});
|
|
150
156
|
|
|
151
157
|
// ---------------------------------------------------------------------------
|
|
@@ -307,6 +313,7 @@ interface PersistedRow {
|
|
|
307
313
|
provenanceSourceChannel: string | undefined;
|
|
308
314
|
provenanceGuardianExternalUserId: string | undefined;
|
|
309
315
|
provenanceRequesterIdentifier: string | undefined;
|
|
316
|
+
provenanceContactId: string | undefined;
|
|
310
317
|
}
|
|
311
318
|
|
|
312
319
|
const EXTERNAL_CONTENT_WRAPPER =
|
|
@@ -340,6 +347,7 @@ function readPersistedSlackRows(conversationId: string): PersistedRow[] {
|
|
|
340
347
|
provenanceSourceChannel: undefined,
|
|
341
348
|
provenanceGuardianExternalUserId: undefined,
|
|
342
349
|
provenanceRequesterIdentifier: undefined,
|
|
350
|
+
provenanceContactId: undefined,
|
|
343
351
|
};
|
|
344
352
|
if (!row.metadata) {
|
|
345
353
|
out.push(blank);
|
|
@@ -402,6 +410,10 @@ function readPersistedSlackRows(conversationId: string): PersistedRow[] {
|
|
|
402
410
|
typeof envelope.provenanceRequesterIdentifier === "string"
|
|
403
411
|
? envelope.provenanceRequesterIdentifier
|
|
404
412
|
: undefined,
|
|
413
|
+
provenanceContactId:
|
|
414
|
+
typeof envelope.provenanceContactId === "string"
|
|
415
|
+
? envelope.provenanceContactId
|
|
416
|
+
: undefined,
|
|
405
417
|
});
|
|
406
418
|
}
|
|
407
419
|
return out;
|
|
@@ -1149,6 +1161,47 @@ describe("triggerSlackThreadBackfillIfNeeded — gap detection and persistence",
|
|
|
1149
1161
|
expect(persisted.provenanceRequesterIdentifier).toBe("U_ANITA");
|
|
1150
1162
|
});
|
|
1151
1163
|
|
|
1164
|
+
test("backfilled thread rows record the sender's gateway trust class and contact id", async () => {
|
|
1165
|
+
const conv = createTestConversation();
|
|
1166
|
+
readInboundTrustSpy.mockImplementationOnce(async () => ({
|
|
1167
|
+
ok: true,
|
|
1168
|
+
verdict: {
|
|
1169
|
+
trustClass: "trusted_contact",
|
|
1170
|
+
canonicalSenderId: "U_ANITA",
|
|
1171
|
+
contactId: "contact-anita",
|
|
1172
|
+
channelId: "channel-anita",
|
|
1173
|
+
status: "active",
|
|
1174
|
+
policy: "allow",
|
|
1175
|
+
},
|
|
1176
|
+
admissionPolicy: null,
|
|
1177
|
+
}));
|
|
1178
|
+
backfillThreadMock.mockImplementation(async () => [
|
|
1179
|
+
makeBackfillMessage({
|
|
1180
|
+
id: "1234.0",
|
|
1181
|
+
text: "the export endpoint needs a scoped token",
|
|
1182
|
+
threadId: undefined,
|
|
1183
|
+
sender: { id: "U_ANITA", name: "Anita" },
|
|
1184
|
+
}),
|
|
1185
|
+
]);
|
|
1186
|
+
|
|
1187
|
+
await triggerSlackThreadBackfillIfNeeded({
|
|
1188
|
+
conversationId: conv.id,
|
|
1189
|
+
channelId: SLACK_CHANNEL_ID,
|
|
1190
|
+
threadTs: "1234.0",
|
|
1191
|
+
guardianExternalUserId: "U_GUARDIAN",
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
const [persisted] = readPersistedSlackRows(conv.id).filter(
|
|
1195
|
+
(p) => p.channelTs === "1234.0",
|
|
1196
|
+
);
|
|
1197
|
+
expect(persisted?.provenanceTrustClass).toBe("trusted_contact");
|
|
1198
|
+
expect(persisted?.provenanceContactId).toBe("contact-anita");
|
|
1199
|
+
expect(readInboundTrustSpy).toHaveBeenLastCalledWith({
|
|
1200
|
+
channelType: "slack",
|
|
1201
|
+
actorExternalId: "U_ANITA",
|
|
1202
|
+
});
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1152
1205
|
test("backfilled Slack timezone metadata derives timestamp and speaker fields", async () => {
|
|
1153
1206
|
const conv = createTestConversation();
|
|
1154
1207
|
setConfiguredUserTimezone("America/Denver");
|
package/src/approvals/AGENTS.md
CHANGED
|
@@ -21,7 +21,7 @@ Conversational guardian verification control-plane invocation is guardian-only.
|
|
|
21
21
|
|
|
22
22
|
## Memory Provenance Invariant
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Memory access follows the acting actor's capabilities: `resolveCapabilities(trustClass).canAccessMemory`, which only `guardian` holds. An actor without it neither reads nor writes the guardian's memory. The memory plugin's `injectors.ts` admits personal-memory content only for a turn whose trust context passes `isPersonalMemoryAllowed`; the `recall`, `remember`, and `delete_memory_page` tools refuse such a turn (`plugins/defaults/memory/tools.ts`); and extraction from conversation messages runs only for guardian-provenance rows, or legacy rows with none (`plugins/defaults/memory/indexer.ts`).
|
|
25
25
|
|
|
26
26
|
## Guardian Privilege Isolation Invariant
|
|
27
27
|
|
|
@@ -25,7 +25,7 @@ no verb to post one.
|
|
|
25
25
|
Examples:
|
|
26
26
|
$ assistant roadmap list --sort upvotes --limit 10
|
|
27
27
|
$ assistant roadmap get scheduled-messages
|
|
28
|
-
$ assistant roadmap create --title "Add dark mode" --tag
|
|
28
|
+
$ assistant roadmap create --title "Add dark mode" --tag area-interface
|
|
29
29
|
$ assistant roadmap upvote scheduled-messages`,
|
|
30
30
|
subcommands: [
|
|
31
31
|
{
|
|
@@ -97,7 +97,7 @@ accepted, since status is assigned by Vellum.
|
|
|
97
97
|
Examples:
|
|
98
98
|
$ assistant roadmap create --title "Add dark mode"
|
|
99
99
|
$ assistant roadmap create --title "Recurring reminders" \\
|
|
100
|
-
--description "Repeat a scheduled message weekly" --tag
|
|
100
|
+
--description "Repeat a scheduled message weekly" --tag area-assistant`,
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
name: "update",
|
|
@@ -130,7 +130,6 @@ assistant does not own.
|
|
|
130
130
|
|
|
131
131
|
Examples:
|
|
132
132
|
$ assistant roadmap update dark-mode --description "Follow the OS setting"
|
|
133
|
-
$ assistant roadmap update dark-mode --tag ui --tag theming
|
|
134
133
|
$ assistant roadmap update dark-mode --clear-tags`,
|
|
135
134
|
},
|
|
136
135
|
{
|
|
@@ -63,7 +63,10 @@ import {
|
|
|
63
63
|
syncMessageToDisk,
|
|
64
64
|
updateMetaFile,
|
|
65
65
|
} from "../persistence/conversation-disk-view.js";
|
|
66
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
isEchoSuppressedUserMessage,
|
|
68
|
+
SIGHT_FRAME_ATTACHMENT_IDS_KEY,
|
|
69
|
+
} from "../persistence/conversation-types.js";
|
|
67
70
|
import {
|
|
68
71
|
attachmentIdFragment,
|
|
69
72
|
type ContentBlock,
|
|
@@ -76,6 +79,7 @@ import type { ConversationModeSessionCoordinator } from "./conversation-mode-ses
|
|
|
76
79
|
import type { MessageQueue } from "./conversation-queue-manager.js";
|
|
77
80
|
import type { SlackInboundMessageMetadata } from "./handlers/shared.js";
|
|
78
81
|
import type { UserMessageAttachment } from "./message-protocol.js";
|
|
82
|
+
import { actorAuthorProvenance } from "./message-provenance.js";
|
|
79
83
|
import type { ConversationTransportMetadata } from "./message-types/conversations.js";
|
|
80
84
|
import { bestEffortModeSessionTracking } from "./mode-session-tracking.js";
|
|
81
85
|
import {
|
|
@@ -964,6 +968,13 @@ export interface PersistMessageOptions {
|
|
|
964
968
|
* persisting a message the current actor just sent.
|
|
965
969
|
*/
|
|
966
970
|
trustContext?: TrustContext;
|
|
971
|
+
/**
|
|
972
|
+
* The person whose own inbound message this row records, passed only by a
|
|
973
|
+
* caller relaying one (channel ingress and its retry replay). It names the
|
|
974
|
+
* row's author (`actorAuthorProvenance`). Machine-authored callers omit it,
|
|
975
|
+
* so their rows name no author whatever the conversation's trust is.
|
|
976
|
+
*/
|
|
977
|
+
author?: TrustContext;
|
|
967
978
|
/**
|
|
968
979
|
* Persist the row without indexing it (no memory segments, embeddings, or
|
|
969
980
|
* lexical-index entry). For machine-authored prompts that must not enter
|
|
@@ -1320,6 +1331,13 @@ export async function persistQueuedMessageBody(
|
|
|
1320
1331
|
const mergedMetadata = {
|
|
1321
1332
|
...metadataWithoutSlackInbound,
|
|
1322
1333
|
...provenance,
|
|
1334
|
+
// A scripted row, or one the repo classes as machine-injected (hidden,
|
|
1335
|
+
// ACP or subagent notification, background event), is not a person's
|
|
1336
|
+
// own words, so even a relayed author is not named on one.
|
|
1337
|
+
...(resolvedScripted ||
|
|
1338
|
+
isEchoSuppressedUserMessage(metadataWithoutSlackInbound)
|
|
1339
|
+
? {}
|
|
1340
|
+
: actorAuthorProvenance(options.author)),
|
|
1323
1341
|
...(turnCtx
|
|
1324
1342
|
? {
|
|
1325
1343
|
userMessageChannel: turnCtx.userMessageChannel,
|
|
@@ -13,9 +13,13 @@
|
|
|
13
13
|
* context compactor (image manifest) can apply the identical filter without
|
|
14
14
|
* creating an import cycle through `conversation-lifecycle` ↔
|
|
15
15
|
* `window-manager` ↔ `compactor`.
|
|
16
|
+
*
|
|
17
|
+
* It also owns the author field (`actorAuthorProvenance`), which says who
|
|
18
|
+
* wrote a row rather than whose turn wrote it.
|
|
16
19
|
*/
|
|
17
20
|
import type { MessageRow } from "../persistence/conversation-crud.js";
|
|
18
21
|
import { type TrustClass, trustClassSchema } from "../runtime/trust-class.js";
|
|
22
|
+
import type { TrustContext } from "./trust-context-types.js";
|
|
19
23
|
|
|
20
24
|
export function parseProvenanceTrustClass(
|
|
21
25
|
metadata: string | null,
|
|
@@ -56,3 +60,22 @@ export function filterMessagesForUntrustedActor(
|
|
|
56
60
|
): MessageRow[] {
|
|
57
61
|
return messages.filter((m) => isRowVisibleToUntrustedActor(m.metadata));
|
|
58
62
|
}
|
|
63
|
+
|
|
64
|
+
/** The persisted author field: the contact who wrote the row. */
|
|
65
|
+
export interface ActorAuthorProvenance {
|
|
66
|
+
provenanceContactId?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Author fields for a row the trust context's actor wrote themselves: their
|
|
71
|
+
* own message or reaction. `provenanceFromTrustContext` describes the turn and
|
|
72
|
+
* is also stamped on the assistant's replies, tool results, and notices, so
|
|
73
|
+
* those rows must never carry these.
|
|
74
|
+
*/
|
|
75
|
+
export function actorAuthorProvenance(
|
|
76
|
+
trustContext: TrustContext | undefined,
|
|
77
|
+
): ActorAuthorProvenance {
|
|
78
|
+
return trustContext?.requesterContactId
|
|
79
|
+
? { provenanceContactId: trustContext.requesterContactId }
|
|
80
|
+
: {};
|
|
81
|
+
}
|
|
@@ -67,12 +67,19 @@ import {
|
|
|
67
67
|
preactivateHostProxySkills,
|
|
68
68
|
shouldAttachHostProxyForCapability,
|
|
69
69
|
} from "./host-proxy-preactivation.js";
|
|
70
|
+
import { actorAuthorProvenance } from "./message-provenance.js";
|
|
70
71
|
import type { SubagentToolGateMode } from "./tool-setup-types.js";
|
|
71
|
-
import { restingTrust } from "./trust-context-types.js";
|
|
72
|
+
import { restingTrust, type TrustContext } from "./trust-context-types.js";
|
|
72
73
|
|
|
73
74
|
const log = getLogger("process-message");
|
|
74
75
|
|
|
75
76
|
type ProcessMessageOptions = ConversationCreateOptions & {
|
|
77
|
+
/**
|
|
78
|
+
* The person whose own inbound message this turn persists, set only by
|
|
79
|
+
* channel ingress and its retry replay. Names the author on the persisted
|
|
80
|
+
* user row; see `PersistMessageOptions.author`.
|
|
81
|
+
*/
|
|
82
|
+
author?: TrustContext;
|
|
76
83
|
/** Per-turn observer for live agent-loop events. Does not replace SSE broadcast. */
|
|
77
84
|
onEvent?: (msg: AssistantEvent) => void;
|
|
78
85
|
/** IDs of user-uploaded attachments to resolve and include in the turn. */
|
|
@@ -539,7 +546,7 @@ export async function processMessage(
|
|
|
539
546
|
...(Object.keys(imageSourcePaths).length > 0 ? { imageSourcePaths } : {}),
|
|
540
547
|
};
|
|
541
548
|
const userMetaWithSlack = withChannelEnvelopes(
|
|
542
|
-
serverChannelMeta,
|
|
549
|
+
{ ...serverChannelMeta, ...actorAuthorProvenance(options?.author) },
|
|
543
550
|
slackMeta,
|
|
544
551
|
providerMeta,
|
|
545
552
|
);
|
|
@@ -638,7 +645,7 @@ export async function processMessage(
|
|
|
638
645
|
: {}),
|
|
639
646
|
};
|
|
640
647
|
const compactUserMeta = withChannelEnvelopes(
|
|
641
|
-
compactChannelMeta,
|
|
648
|
+
{ ...compactChannelMeta, ...actorAuthorProvenance(options?.author) },
|
|
642
649
|
slackMeta,
|
|
643
650
|
providerMeta,
|
|
644
651
|
);
|
|
@@ -698,7 +705,7 @@ export async function processMessage(
|
|
|
698
705
|
: {}),
|
|
699
706
|
};
|
|
700
707
|
const cleanUserMeta = withChannelEnvelopes(
|
|
701
|
-
cleanChannelMeta,
|
|
708
|
+
{ ...cleanChannelMeta, ...actorAuthorProvenance(options?.author) },
|
|
702
709
|
slackMeta,
|
|
703
710
|
providerMeta,
|
|
704
711
|
);
|
|
@@ -744,6 +751,7 @@ export async function processMessage(
|
|
|
744
751
|
requestId,
|
|
745
752
|
metadata: persistMetadata,
|
|
746
753
|
displayContent: options?.displayContent,
|
|
754
|
+
...(options?.author ? { author: options.author } : {}),
|
|
747
755
|
...(options?.skipUserMessageIndexing ? { skipIndexing: true } : {}),
|
|
748
756
|
...(ingressKey ? { clientMessageId: ingressKey } : {}),
|
|
749
757
|
},
|
|
@@ -825,6 +833,7 @@ export async function processMessageInBackground(
|
|
|
825
833
|
requestId,
|
|
826
834
|
metadata: persistMetadata,
|
|
827
835
|
displayContent: options?.displayContent,
|
|
836
|
+
...(options?.author ? { author: options.author } : {}),
|
|
828
837
|
...(ingressKey ? { clientMessageId: ingressKey } : {}),
|
|
829
838
|
},
|
|
830
839
|
);
|
|
@@ -402,6 +402,14 @@ export const messageMetadataSchema = z
|
|
|
402
402
|
provenanceSourceChannel: channelIdSchema.optional(),
|
|
403
403
|
provenanceGuardianExternalUserId: z.string().optional(),
|
|
404
404
|
provenanceRequesterIdentifier: z.string().optional(),
|
|
405
|
+
/**
|
|
406
|
+
* Contact id of the person who wrote this row, from the gateway trust
|
|
407
|
+
* verdict at persist time. Stamped only on a person's own message or
|
|
408
|
+
* reaction (`actorAuthorProvenance`), never on rows the assistant writes
|
|
409
|
+
* during their turn: the other `provenance*` fields describe the turn,
|
|
410
|
+
* this one the author. Absent when the author resolved to no contact.
|
|
411
|
+
*/
|
|
412
|
+
provenanceContactId: z.string().optional(),
|
|
405
413
|
automated: z.boolean().optional(),
|
|
406
414
|
/**
|
|
407
415
|
* Transcript-suppression flag: the row is a machine signal (e.g. the
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daily archive (`memory/archive/`) holds raw buffer entries only. Per-turn
|
|
3
|
+
* injection reads concept pages and never the archive, and text a pass
|
|
4
|
+
* composes or derives on a page is not in it. So nothing the consolidation
|
|
5
|
+
* agent reads may present the archive as making removal safe: that framing
|
|
6
|
+
* licenses deleting filed content that exists nowhere else.
|
|
7
|
+
*
|
|
8
|
+
* The check is sentence-level: no sentence may pair the archive with
|
|
9
|
+
* reassurance that removing content loses nothing. A sentence warning that
|
|
10
|
+
* removed text IS lost stays allowed.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
import { renderConsolidationPrompt } from "../substrate/prompts/consolidation.js";
|
|
15
|
+
import { deleteMemoryPageTool } from "../tools.js";
|
|
16
|
+
|
|
17
|
+
function archiveSafetyClaims(text: string): string[] {
|
|
18
|
+
return text
|
|
19
|
+
.split(/(?<=[.!?])\s+/)
|
|
20
|
+
.filter(
|
|
21
|
+
(sentence) =>
|
|
22
|
+
/archive/i.test(sentence) &&
|
|
23
|
+
/don.t worry|no need to worry|never los|nothing is lost|won.t lose|safe to (?:delete|remove|cut|drop)/i.test(
|
|
24
|
+
sentence,
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("the archive is never framed as making removal safe", () => {
|
|
30
|
+
test("neither consolidation article shape makes the claim", () => {
|
|
31
|
+
const v2 = renderConsolidationPrompt("May 1, 12:00 PM", {
|
|
32
|
+
includeCorePagesSection: false,
|
|
33
|
+
articleShape: "v2",
|
|
34
|
+
bufferEntries: "",
|
|
35
|
+
});
|
|
36
|
+
// The over-long-sections step carries the one legitimate archive
|
|
37
|
+
// sentence, so the v3 render includes it.
|
|
38
|
+
const v3 = renderConsolidationPrompt("May 1, 12:00 PM", {
|
|
39
|
+
includeCorePagesSection: true,
|
|
40
|
+
articleShape: "v3",
|
|
41
|
+
bufferEntries: "",
|
|
42
|
+
overlongSections: {
|
|
43
|
+
windowChars: 6000,
|
|
44
|
+
sections: [{ slug: "journal", title: "", chars: 7200 }],
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(v3).toContain("the archive is a record");
|
|
49
|
+
expect(archiveSafetyClaims(v2)).toEqual([]);
|
|
50
|
+
expect(archiveSafetyClaims(v3)).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a warning that removed text is lost is not the claim", () => {
|
|
54
|
+
expect(
|
|
55
|
+
archiveSafetyClaims(
|
|
56
|
+
"Text you cut from a page is lost to retrieval: the archive does not bring it back.",
|
|
57
|
+
),
|
|
58
|
+
).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("the delete_memory_page description does not make the claim", () => {
|
|
62
|
+
expect(archiveSafetyClaims(deleteMemoryPageTool.description)).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -312,7 +312,7 @@ Cutoff timestamp for this run: \`${CUTOFF_PLACEHOLDER}\`. The entries for this p
|
|
|
312
312
|
- Updated \`memory/threads.md\` (≤10000 chars)
|
|
313
313
|
- Updated \`edges:\` frontmatter in any pages whose outgoing links changed
|
|
314
314
|
|
|
315
|
-
How retrieval works: high-activation pages are loaded at the start of each turn. Activations spread along **directed** edges from source to target
|
|
315
|
+
How retrieval works: high-activation pages are loaded at the start of each turn. Activations spread along **directed** edges from source to target: activating A pulls in the pages A points at, but not the reverse.
|
|
316
316
|
|
|
317
317
|
---
|
|
318
318
|
|
|
@@ -724,7 +724,7 @@ Three consequences:
|
|
|
724
724
|
|
|
725
725
|
1. **The lead IS the card, and the fallback.** Write every lead as a standalone orientation: what this article is, the one or two facts that identify it, where it sits. If the lead only makes sense after reading the sections, the card is useless and so is the fallback. One to three short paragraphs.
|
|
726
726
|
2. **Section names are navigation.** They appear on the selector's card as the table of contents and head every injected section. Name sections so future-you can tell from the name alone whether the answer lives there.
|
|
727
|
-
3. **Sections are the unit of growth and retrieval.** A fact filed in the right section of the right article is findable and arrives in context with its section (kept under the 6000-character chunk window, so the section travels whole); a fact buried mid-paragraph in an overlong lead is not.
|
|
727
|
+
3. **Sections are the unit of growth and retrieval.** A fact filed in the right section of the right article is findable and arrives in context with its section (kept under the 6000-character chunk window, so the section travels whole); a fact buried mid-paragraph in an overlong lead is not.
|
|
728
728
|
|
|
729
729
|
---
|
|
730
730
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import {
|
|
@@ -305,6 +305,29 @@ describe("rememberTool.execute — buffer writes", () => {
|
|
|
305
305
|
});
|
|
306
306
|
});
|
|
307
307
|
|
|
308
|
+
describe("rememberTool.execute — memory access", () => {
|
|
309
|
+
test.each(["trusted_contact", "unverified_contact", "unknown"] as const)(
|
|
310
|
+
"refuses a %s write without touching the memory buffer",
|
|
311
|
+
async (trustClass) => {
|
|
312
|
+
const fact = `a claim made on a ${trustClass} turn`;
|
|
313
|
+
const result = await rememberTool.execute(
|
|
314
|
+
{ content: fact, finish_turn: true },
|
|
315
|
+
makeContext({ trustClass }),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
expect(result.isError).toBe(true);
|
|
319
|
+
expect(result.content).toContain("Not saved");
|
|
320
|
+
// The turn continues so the model can relay the refusal.
|
|
321
|
+
expect(result.yieldToUser).toBeUndefined();
|
|
322
|
+
const bufferPath = join(tmpWorkspace, "memory", "buffer.md");
|
|
323
|
+
const buffer = existsSync(bufferPath)
|
|
324
|
+
? readFileSync(bufferPath, "utf-8")
|
|
325
|
+
: "";
|
|
326
|
+
expect(buffer).not.toContain(fact);
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
308
331
|
describe("rememberTool.execute — batch (array) content", () => {
|
|
309
332
|
beforeEach(() => {
|
|
310
333
|
enqueueCalls.length = 0;
|
|
@@ -55,6 +55,17 @@ export const rememberTool = {
|
|
|
55
55
|
const typedInput = input as unknown as RememberInput;
|
|
56
56
|
// The append below writes the memory buffer, so a cancelled turn stops here.
|
|
57
57
|
throwIfCancelled(context);
|
|
58
|
+
if (!resolveCapabilities(context.trustClass).canAccessMemory) {
|
|
59
|
+
// No yield even when finish_turn is set: the model has to see this, so
|
|
60
|
+
// it never tells someone a fact was saved when it was not. An error, not
|
|
61
|
+
// a quiet no-op: a non-error `remember` result reads as a durable write to
|
|
62
|
+
// `memory-run-evidence`.
|
|
63
|
+
return {
|
|
64
|
+
content:
|
|
65
|
+
"Not saved: remember writes the guardian's long-term memory, which is only available on the guardian's own turns. Retrying will not help. Tell the person only if they asked you to remember this or you said you would.",
|
|
66
|
+
isError: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
58
69
|
const result = handleRemember(
|
|
59
70
|
typedInput,
|
|
60
71
|
context.conversationId,
|
|
@@ -127,7 +138,7 @@ export const recallTool = {
|
|
|
127
138
|
export const deleteMemoryPageTool = {
|
|
128
139
|
name: "delete_memory_page",
|
|
129
140
|
description:
|
|
130
|
-
"Delete one concept page from your memory wiki, addressed by slug (its path under `memory/concepts/` minus `.md`, e.g. `alice`, `people/alice`, `procs/git-flow`). Use during a consolidation/maintenance pass to retire a page you merged into another, renamed (write the new page, then delete the old slug), or dropped as a dead stub. Only concept pages can be deleted: the index files (`recent.md`, `essentials.md`, `threads.md`) are rewritten with file_write/file_edit, never deleted, and `buffer.md` is never written by you at all; the runtime removes the entries it handed you once the pass completes. Idempotent: deleting a slug that is already gone is not an error.
|
|
141
|
+
"Delete one concept page from your memory wiki, addressed by slug (its path under `memory/concepts/` minus `.md`, e.g. `alice`, `people/alice`, `procs/git-flow`). Use during a consolidation/maintenance pass to retire a page you merged into another, renamed (write the new page, then delete the old slug), or dropped as a dead stub. Only concept pages can be deleted: the index files (`recent.md`, `essentials.md`, `threads.md`) are rewritten with file_write/file_edit, never deleted, and `buffer.md` is never written by you at all; the runtime removes the entries it handed you once the pass completes. Idempotent: deleting a slug that is already gone is not an error.",
|
|
131
142
|
category: "memory",
|
|
132
143
|
executionTarget: "sandbox",
|
|
133
144
|
defaultRiskLevel: RiskLevel.Low,
|
|
@@ -53,6 +53,7 @@ import { appendEventToStream } from "../signals/event-stream.js";
|
|
|
53
53
|
import { getLogger } from "../util/logger.js";
|
|
54
54
|
import { buildAssistantEvent } from "./assistant-event.js";
|
|
55
55
|
import type { AssistantEventPublishOptions } from "./assistant-event-publish-options.js";
|
|
56
|
+
import { matchesTargeting } from "./assistant-event-targeting.js";
|
|
56
57
|
import { stampAndBuffer } from "./assistant-stream-state.js";
|
|
57
58
|
import { isMainDaemonProcess } from "./process-role.js";
|
|
58
59
|
|
|
@@ -323,24 +324,14 @@ export class AssistantEventHub {
|
|
|
323
324
|
* Publish an event to all matching subscribers.
|
|
324
325
|
*
|
|
325
326
|
* Matching rules:
|
|
326
|
-
* -
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
* -
|
|
331
|
-
*
|
|
332
|
-
* event's conversationId
|
|
333
|
-
*
|
|
334
|
-
* - if `filter.conversationId` is set (and `targetClientId` is not), the
|
|
335
|
-
* `event.conversationId` must equal it
|
|
336
|
-
* - if `targetCapability` is set, only subscribers whose capabilities include
|
|
337
|
-
* it receive the event; untargeted events go to all
|
|
338
|
-
* - if `targetInterfaceId` is set, only client subscribers whose
|
|
339
|
-
* `interfaceId` matches receive the event; process subscribers and
|
|
340
|
-
* non-matching clients are skipped.
|
|
341
|
-
* - if `targetActorPrincipalId` is set, only client subscribers whose
|
|
342
|
-
* verified `actorPrincipalId` equals it receive the event; process
|
|
343
|
-
* subscribers and clients without a principal are skipped.
|
|
327
|
+
* - the event's targeting (`excludeClientId`, `targetInterfaceId`,
|
|
328
|
+
* `targetActorPrincipalId`, `targetClientId`, `targetCapability`) must
|
|
329
|
+
* admit the subscriber, per {@link matchesTargeting}, the same check
|
|
330
|
+
* replay applies
|
|
331
|
+
* - a subscriber's `filter.conversationId` must equal `event.conversationId`,
|
|
332
|
+
* unless the event names a client with `targetClientId` (a web-origin
|
|
333
|
+
* event's conversationId can differ from the conversation the targeted
|
|
334
|
+
* macOS client subscribed to)
|
|
344
335
|
*
|
|
345
336
|
* Fanout is isolated: a throwing or rejecting subscriber does not abort
|
|
346
337
|
* delivery to remaining subscribers.
|
|
@@ -365,11 +356,6 @@ export class AssistantEventHub {
|
|
|
365
356
|
}
|
|
366
357
|
}
|
|
367
358
|
|
|
368
|
-
const targetCapability = options?.targetCapability;
|
|
369
|
-
const targetClientId = options?.targetClientId;
|
|
370
|
-
const targetInterfaceId = options?.targetInterfaceId;
|
|
371
|
-
const targetActorPrincipalId = options?.targetActorPrincipalId;
|
|
372
|
-
const excludeClientId = options?.excludeClientId;
|
|
373
359
|
const snapshot = Array.from(this.subscribers);
|
|
374
360
|
const errors: unknown[] = [];
|
|
375
361
|
|
|
@@ -377,74 +363,18 @@ export class AssistantEventHub {
|
|
|
377
363
|
if (!entry.active) {
|
|
378
364
|
continue;
|
|
379
365
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
// both targeted and untargeted broadcasts.
|
|
366
|
+
if (!matchesTargeting(options, entry)) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
384
369
|
if (
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
entry.
|
|
370
|
+
options?.targetClientId == null &&
|
|
371
|
+
event.conversationId != null &&
|
|
372
|
+
entry.filter.conversationId != null &&
|
|
373
|
+
entry.filter.conversationId !== event.conversationId
|
|
388
374
|
) {
|
|
389
375
|
continue;
|
|
390
376
|
}
|
|
391
377
|
|
|
392
|
-
// Interface targeting: skip any subscriber that is not a client of
|
|
393
|
-
// the requested interface. Composes with `targetClientId` and
|
|
394
|
-
// `targetCapability` below.
|
|
395
|
-
if (targetInterfaceId != null) {
|
|
396
|
-
if (
|
|
397
|
-
entry.type !== "client" ||
|
|
398
|
-
entry.interfaceId !== targetInterfaceId
|
|
399
|
-
) {
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Principal targeting: an event scoped to one person reaches only that
|
|
405
|
-
// person's own connections. Composes with every other rule.
|
|
406
|
-
if (targetActorPrincipalId != null) {
|
|
407
|
-
if (
|
|
408
|
-
entry.type !== "client" ||
|
|
409
|
-
entry.actorPrincipalId !== targetActorPrincipalId
|
|
410
|
-
) {
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (targetClientId != null) {
|
|
416
|
-
// Targeted: bypass conversation filter, deliver only to the named client.
|
|
417
|
-
if (entry.type !== "client" || entry.clientId !== targetClientId) {
|
|
418
|
-
continue;
|
|
419
|
-
}
|
|
420
|
-
if (
|
|
421
|
-
targetCapability != null &&
|
|
422
|
-
!entry.capabilities.includes(targetCapability)
|
|
423
|
-
) {
|
|
424
|
-
continue;
|
|
425
|
-
}
|
|
426
|
-
} else {
|
|
427
|
-
// Untargeted: existing conversation-scoped + capability logic.
|
|
428
|
-
if (
|
|
429
|
-
event.conversationId != null &&
|
|
430
|
-
entry.filter.conversationId != null &&
|
|
431
|
-
entry.filter.conversationId !== event.conversationId
|
|
432
|
-
) {
|
|
433
|
-
continue;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Capability targeting: targeted events only go to subscribers that
|
|
437
|
-
// declare the required capability.
|
|
438
|
-
if (targetCapability != null) {
|
|
439
|
-
if (
|
|
440
|
-
entry.type !== "client" ||
|
|
441
|
-
!entry.capabilities.includes(targetCapability)
|
|
442
|
-
) {
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
378
|
try {
|
|
449
379
|
await entry.callback(event);
|
|
450
380
|
} catch (err) {
|