@sentry/junior-memory 0.91.0 → 0.92.1

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/dist/recall.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PromptMessage, Requester, Source } from "@sentry/junior-plugin-api";
1
+ import type { PromptMessage, Actor, Source } from "@sentry/junior-plugin-api";
2
2
  import { type MemoryDb, type MemoryEmbeddingProvider } from "./store";
3
3
  export interface MemoryRecallContext {
4
4
  conversationId?: string;
@@ -6,7 +6,7 @@ export interface MemoryRecallContext {
6
6
  embedder?: MemoryEmbeddingProvider;
7
7
  /** Maximum cosine distance for vector recall. Passed through to the memory store. */
8
8
  maxVectorDistance?: number;
9
- requester?: Requester;
9
+ actor?: Actor;
10
10
  source: Source;
11
11
  text: string;
12
12
  }
package/dist/store.d.ts CHANGED
@@ -118,7 +118,7 @@ export interface MemoryStore {
118
118
  archiveExpiredMemories(input?: ArchiveExpiredMemoriesInput): Promise<ArchiveExpiredMemoriesResult>;
119
119
  /** Archive a visible memory in the current runtime context. */
120
120
  archiveMemory(input: ArchiveMemoryInput): Promise<MemoryRecord>;
121
- /** Store a personal memory for the current requester. */
121
+ /** Store a personal memory for the current actor. */
122
122
  createMemory(input: CreateMemoryInput): Promise<CreateMemoryResult>;
123
123
  /** Store a conversation memory for the current source conversation. */
124
124
  createConversationMemory(input: CreateMemoryInput): Promise<CreateMemoryResult>;
package/dist/tools.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Source, type Requester } from "@sentry/junior-plugin-api";
1
+ import { type Source, type Actor } from "@sentry/junior-plugin-api";
2
2
  import { type MemoryEmbeddingProvider, type MemoryDb, type MemorySupersessionDecider } from "./store";
3
3
  import { type MemoryAgent } from "./agent";
4
4
  export type MemoryReviewer = Pick<MemoryAgent, "reviewCreateRequest">;
@@ -8,7 +8,7 @@ export interface MemoryToolContext {
8
8
  conversationId?: string;
9
9
  db: MemoryDb;
10
10
  embedder?: MemoryEmbeddingProvider;
11
- requester?: Requester;
11
+ actor?: Actor;
12
12
  source: Source;
13
13
  userText?: string;
14
14
  }
package/dist/types.d.ts CHANGED
@@ -13,7 +13,7 @@ export type MemoryEmbeddingMetric = (typeof MEMORY_EMBEDDING_METRICS)[number];
13
13
  /** Runtime-owned memory invocation fields used for scope and source authority. */
14
14
  export declare const slackMemoryRuntimeContextSchema: z.ZodObject<{
15
15
  conversationId: z.ZodOptional<z.ZodString>;
16
- requester: z.ZodOptional<z.ZodObject<{
16
+ actor: z.ZodOptional<z.ZodObject<{
17
17
  platform: z.ZodLiteral<"slack">;
18
18
  teamId: z.ZodString;
19
19
  email: z.ZodOptional<z.ZodString>;
@@ -36,7 +36,7 @@ export declare const slackMemoryRuntimeContextSchema: z.ZodObject<{
36
36
  /** Runtime-owned local memory invocation fields used for scope and source authority. */
37
37
  export declare const localMemoryRuntimeContextSchema: z.ZodObject<{
38
38
  conversationId: z.ZodOptional<z.ZodString>;
39
- requester: z.ZodOptional<z.ZodObject<{
39
+ actor: z.ZodOptional<z.ZodObject<{
40
40
  platform: z.ZodLiteral<"local">;
41
41
  email: z.ZodOptional<z.ZodString>;
42
42
  fullName: z.ZodOptional<z.ZodString>;
@@ -52,7 +52,7 @@ export declare const localMemoryRuntimeContextSchema: z.ZodObject<{
52
52
  /** Runtime-owned memory invocation fields accepted by memory store operations. */
53
53
  export declare const memoryRuntimeContextSchema: z.ZodUnion<readonly [z.ZodObject<{
54
54
  conversationId: z.ZodOptional<z.ZodString>;
55
- requester: z.ZodOptional<z.ZodObject<{
55
+ actor: z.ZodOptional<z.ZodObject<{
56
56
  platform: z.ZodLiteral<"slack">;
57
57
  teamId: z.ZodString;
58
58
  email: z.ZodOptional<z.ZodString>;
@@ -73,7 +73,7 @@ export declare const memoryRuntimeContextSchema: z.ZodUnion<readonly [z.ZodObjec
73
73
  }, z.core.$strict>;
74
74
  }, z.core.$strict>, z.ZodObject<{
75
75
  conversationId: z.ZodOptional<z.ZodString>;
76
- requester: z.ZodOptional<z.ZodObject<{
76
+ actor: z.ZodOptional<z.ZodObject<{
77
77
  platform: z.ZodLiteral<"local">;
78
78
  email: z.ZodOptional<z.ZodString>;
79
79
  fullName: z.ZodOptional<z.ZodString>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-memory",
3
- "version": "0.91.0",
3
+ "version": "0.92.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -27,7 +27,7 @@
27
27
  "commander": "^14.0.3",
28
28
  "drizzle-orm": "^0.45.2",
29
29
  "zod": "^4.4.3",
30
- "@sentry/junior-plugin-api": "0.91.0"
30
+ "@sentry/junior-plugin-api": "0.92.1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^25.9.1",
package/src/agent.ts CHANGED
@@ -1,10 +1,14 @@
1
- import type { PluginModel } from "@sentry/junior-plugin-api";
1
+ import { actorSchema, type PluginModel } from "@sentry/junior-plugin-api";
2
2
  import { z } from "zod";
3
3
  import type {
4
4
  MemorySupersessionDecision,
5
5
  MemorySupersessionInput,
6
6
  } from "./store";
7
- import { MEMORY_KINDS, memoryRuntimeContextSchema } from "./types";
7
+ import {
8
+ MEMORY_KINDS,
9
+ memoryRuntimeContextSchema,
10
+ type MemoryKind,
11
+ } from "./types";
8
12
 
9
13
  const memoryKindSchema = z.enum(MEMORY_KINDS);
10
14
  const memoryRejectReasonSchema = z.enum([
@@ -30,6 +34,17 @@ const createMemoryRequestSchema = z
30
34
  .optional(),
31
35
  })
32
36
  .strict();
37
+ const transcriptProvenanceSchema = z
38
+ .object({
39
+ authority: z.enum(["instruction", "context"]),
40
+ actor: actorSchema.optional(),
41
+ })
42
+ .strict();
43
+ const evidenceMessageIndicesSchema = z
44
+ .array(z.number().int().nonnegative())
45
+ .min(1)
46
+ .max(10)
47
+ .describe("Indices from <run-transcript> that directly support this memory.");
33
48
  const extractSessionRequestSchema = z
34
49
  .object({
35
50
  existingMemories: z
@@ -42,6 +57,7 @@ const extractSessionRequestSchema = z
42
57
  )
43
58
  .max(10)
44
59
  .default([]),
60
+ actors: z.array(actorSchema),
45
61
  runtimeContext: memoryRuntimeContextSchema,
46
62
  transcript: z
47
63
  .array(
@@ -51,6 +67,8 @@ const extractSessionRequestSchema = z
51
67
  type: z.literal("message"),
52
68
  role: z.enum(["user", "assistant"]),
53
69
  text: z.string().min(1),
70
+ provenance: transcriptProvenanceSchema.optional(),
71
+ isRunActor: z.boolean().optional(),
54
72
  })
55
73
  .strict(),
56
74
  z
@@ -117,13 +135,13 @@ const memoryReviewResponseSchema = z.discriminatedUnion("decision", [
117
135
  .object({
118
136
  decision: z.literal("store"),
119
137
  kind: memoryKindSchema.describe(
120
- "Use preference only for requester-owned personal preferences, opinions, habits, or workflows. Use procedure for reusable task or process instructions. Use knowledge for shared project, channel, operational, or runbook facts.",
138
+ "Use preference only for actor-owned personal preferences, opinions, habits, or workflows. Use procedure for reusable task or process instructions. Use knowledge for shared project, channel, operational, or runbook facts.",
121
139
  ),
122
140
  canonicalFact: z
123
141
  .string()
124
142
  .min(1)
125
143
  .describe(
126
- "Stored memory text. It must be self-contained and must not include requester names, requester/user labels, source labels, or first- or second-person wording.",
144
+ "Stored memory text. It must be self-contained and must not include actor names, actor/user labels, source labels, or first- or second-person wording.",
127
145
  ),
128
146
  expiresAtMs: expiresAtMsSchema,
129
147
  })
@@ -138,15 +156,16 @@ const memoryReviewResponseSchema = z.discriminatedUnion("decision", [
138
156
  const extractedMemorySchema = z
139
157
  .object({
140
158
  kind: memoryKindSchema.describe(
141
- "Use preference only for requester-owned personal preferences, opinions, habits, or workflows. Use procedure for reusable task or process instructions. Use knowledge for shared project, channel, operational, or runbook facts.",
159
+ "Use preference only for actor-owned personal preferences, opinions, habits, or workflows. Use procedure for reusable task or process instructions. Use knowledge for shared project, channel, operational, or runbook facts.",
142
160
  ),
143
161
  canonicalFact: z
144
162
  .string()
145
163
  .min(1)
146
164
  .describe(
147
- "Stored memory text as one self-contained fact. It must not include requester names, requester/user labels, source labels, or first- or second-person wording.",
165
+ "Stored memory text as one self-contained fact. It must not include actor names, actor/user labels, source labels, or first- or second-person wording.",
148
166
  ),
149
167
  expiresAtMs: expiresAtMsSchema,
168
+ evidenceMessageIndices: evidenceMessageIndicesSchema,
150
169
  })
151
170
  .strict();
152
171
  const extractedMemoryResultSchema = z
@@ -154,6 +173,7 @@ const extractedMemoryResultSchema = z
154
173
  content: z.string().min(1),
155
174
  expiresAtMs: expiresAtMsSchema,
156
175
  kind: memoryKindSchema,
176
+ evidenceMessageIndices: evidenceMessageIndicesSchema,
157
177
  })
158
178
  .strict();
159
179
  const extractMemoriesResponseSchema = z
@@ -220,7 +240,7 @@ const MEMORY_EXTRACTION_SYSTEM = [
220
240
  ].join("\n");
221
241
  const MEMORY_SUPERSESSION_SYSTEM = [
222
242
  "You are Junior's memory supersession agent.",
223
- "Decide whether a new requester preference clearly replaces existing active requester preferences.",
243
+ "Decide whether a new actor preference clearly replaces existing active actor preferences.",
224
244
  "Return supersedes_old only for obvious changed preferences about the same mutable slot.",
225
245
  "If the facts are additive, different topics, duplicate, broader/narrower without direct replacement, or uncertain, do not supersede.",
226
246
  ].join("\n");
@@ -229,9 +249,9 @@ const CANONICAL_CONTENT_RULES = [
229
249
  "- Store the minimum useful assertion supported by source evidence; do not add adjacent steps, caveats, or generalized advice.",
230
250
  "- Do not return both concise and expanded variants of the same source assertion; keep the shortest self-contained canonical memory.",
231
251
  "- Put ownership in structured fields, not prose.",
232
- "- For requester memories, omit the subject and write a stable fact such as 'Prefers X', 'Uses Y', or 'Thinks Z'.",
252
+ "- For actor memories, omit the subject and write a stable fact such as 'Prefers X', 'Uses Y', or 'Thinks Z'.",
233
253
  "- Drop perspective/provenance markers while preserving useful context.",
234
- "- Remove requester names, display names, requester/user labels, first- or second-person wording, thread labels, channel labels, and source labels.",
254
+ "- Remove actor names, display names, actor/user labels, first- or second-person wording, thread labels, channel labels, and source labels.",
235
255
  ];
236
256
 
237
257
  function escapeXml(value: string): string {
@@ -245,18 +265,18 @@ function runtimeDescription(
245
265
  request: Pick<CreateMemoryRequest, "expiresAtMs" | "runtimeContext">,
246
266
  ): string {
247
267
  const runtime = request.runtimeContext;
248
- const requester =
249
- runtime.requester?.platform === "slack"
250
- ? `slack:${runtime.requester.teamId}:${runtime.requester.userId}`
251
- : runtime.requester?.platform === "local"
252
- ? `local:${runtime.requester.userId}`
268
+ const actor =
269
+ runtime.actor?.platform === "slack"
270
+ ? `slack:${runtime.actor.teamId}:${runtime.actor.userId}`
271
+ : runtime.actor?.platform === "local"
272
+ ? `local:${runtime.actor.userId}`
253
273
  : "none";
254
274
  const source =
255
275
  runtime.source.platform === "slack"
256
276
  ? `slack:${runtime.source.teamId}:${runtime.source.channelId}`
257
277
  : `local:${runtime.source.conversationId}`;
258
278
  const lines = [
259
- `- requester: ${escapeXml(requester)}`,
279
+ `- actor: ${escapeXml(actor)}`,
260
280
  `- source: ${escapeXml(source)}`,
261
281
  `- has_conversation: ${runtime.conversationId ? "true" : "false"}`,
262
282
  `- expires_at: ${
@@ -295,14 +315,29 @@ function existingMemoriesContext(request: ExtractSessionRequest): string {
295
315
  ].join("\n");
296
316
  }
297
317
 
298
- function memoryKindsContext(): string {
299
- return [
300
- "<memory-kinds>",
301
- "- preference: a durable first-person personal preference, opinion, habit, or workflow owned by the current requester. Stored as requester memory.",
318
+ /**
319
+ * Passive extraction offers personal preferences only on single-actor runs.
320
+ * Multi-actor runs restrict extraction to conversation-scoped kinds.
321
+ */
322
+ function allowedExtractionKinds(actorCount: number): Set<MemoryKind> {
323
+ return actorCount === 1
324
+ ? new Set<MemoryKind>(MEMORY_KINDS)
325
+ : new Set<MemoryKind>(["procedure", "knowledge"]);
326
+ }
327
+
328
+ function memoryKindsContext(allowedKinds: Set<MemoryKind>): string {
329
+ const lines = ["<memory-kinds>"];
330
+ if (allowedKinds.has("preference")) {
331
+ lines.push(
332
+ "- preference: a durable first-person personal preference, opinion, habit, or workflow owned by the current actor. Stored as actor memory.",
333
+ );
334
+ }
335
+ lines.push(
302
336
  "- procedure: reusable instructions for how a task, lookup, investigation, process, triage flow, or runbook should be done. Store the method, source-of-truth, prerequisite, or decision path when it took effort to discover. Stored as conversation memory.",
303
- "- knowledge: stable shared project, channel, operational, or runbook fact that is not a personal requester preference. Direct answers to user inquiries qualify only when they are durable beyond this run. Stored as conversation memory.",
337
+ "- knowledge: stable shared project, channel, operational, or runbook fact that is not a personal actor preference. Direct answers to user inquiries qualify only when they are durable beyond this run. Stored as conversation memory.",
304
338
  "</memory-kinds>",
305
- ].join("\n");
339
+ );
340
+ return lines.join("\n");
306
341
  }
307
342
 
308
343
  function reviewPrompt(request: CreateMemoryRequest): string {
@@ -321,17 +356,17 @@ function reviewPrompt(request: CreateMemoryRequest): string {
321
356
  "<rules>",
322
357
  "- Return store only when the candidate is public/shareable, durable, and self-contained.",
323
358
  "- First classify the memory kind: preference, procedure, or knowledge.",
324
- "- Use kind=preference only for first-person facts authored by the current requester about their own preference, opinion, habit, identity, or workflow.",
325
- "- Reject named third-person personal facts such as another person's preference, opinion, habit, identity, relationship, or workflow. Do not assume a named person is the current requester.",
359
+ "- Use kind=preference only for first-person facts authored by the current actor about their own preference, opinion, habit, identity, or workflow.",
360
+ "- Reject named third-person personal facts such as another person's preference, opinion, habit, identity, relationship, or workflow. Do not assume a named person is the current actor.",
326
361
  "- Use kind=procedure for reusable task/process/runbook instructions.",
327
362
  "- Use kind=knowledge for shared project, channel, operational, or runbook facts.",
328
363
  "- When current-user-message contains an explicit memory request with a concrete fact or procedure, extract from current-user-message even if the candidate is vague, incomplete, or phrased as an instruction.",
329
- "- A candidate may be badly phrased by an outer assistant or extraction pass. When current-user-message contains the requester's own first-person memory fact, treat that as requester-authored source evidence and canonicalize the fact instead of rejecting for third-person wording.",
364
+ "- A candidate may be badly phrased by an outer assistant or extraction pass. When current-user-message contains the actor's own first-person memory fact, treat that as actor-authored source evidence and canonicalize the fact instead of rejecting for third-person wording.",
330
365
  "- When candidate wording personalizes a shared task, process, runbook, project, channel, or operational fact, use current-user-message to recover the shared fact and classify it as procedure or knowledge.",
331
366
  "- Explicit procedure requests are valid when the source text contains both task context and action. Canonicalize them as shared procedure facts instead of rejecting them as vague.",
332
367
  "- Store content as person-less, source-less canonical knowledge. Ownership and source live in structured metadata, not prose.",
333
- "- For requester memories, omit the subject and write the content as a stable fact such as 'Prefers X', 'Uses Y', or 'Thinks Z'.",
334
- "- Remove requester names, display names, requester/user labels, first- or second-person wording, thread labels, channel labels, and source labels from stored content.",
368
+ "- For actor memories, omit the subject and write the content as a stable fact such as 'Prefers X', 'Uses Y', or 'Thinks Z'.",
369
+ "- Remove actor names, display names, actor/user labels, first- or second-person wording, thread labels, channel labels, and source labels from stored content.",
335
370
  "- Reject third-party personal profile facts, even if they mention a name.",
336
371
  "- Reject vague content such as 'remember this' unless the candidate or current-user-message contains the concrete fact.",
337
372
  "- Preserve the requested expiration when one exists; otherwise set expiresAtMs to null.",
@@ -342,6 +377,20 @@ function reviewPrompt(request: CreateMemoryRequest): string {
342
377
  return sections.join("\n");
343
378
  }
344
379
 
380
+ function transcriptActorLabel(
381
+ actor: z.output<typeof actorSchema> | undefined,
382
+ ): string {
383
+ if (!actor) {
384
+ return "none";
385
+ }
386
+ if (actor.platform === "system") {
387
+ return `system:${actor.name}`;
388
+ }
389
+ return actor.platform === "slack"
390
+ ? `slack:${actor.teamId}:${actor.userId}`
391
+ : `local:${actor.userId}`;
392
+ }
393
+
345
394
  function runTranscriptContext(request: ExtractSessionRequest): string {
346
395
  return [
347
396
  "<run-transcript>",
@@ -353,8 +402,11 @@ function runTranscriptContext(request: ExtractSessionRequest): string {
353
402
  "</tool-result>",
354
403
  ].join("\n");
355
404
  }
405
+ const authority = entry.provenance?.authority ?? "context";
406
+ const isRunActor = entry.isRunActor === true;
407
+ const actor = transcriptActorLabel(entry.provenance?.actor);
356
408
  return [
357
- `<message index="${index}" role="${entry.role}">`,
409
+ `<message index="${index}" role="${entry.role}" authority="${authority}" is_run_actor="${isRunActor ? "true" : "false"}" actor="${escapeXml(actor)}">`,
358
410
  escapeXml(entry.text),
359
411
  "</message>",
360
412
  ].join("\n");
@@ -364,6 +416,8 @@ function runTranscriptContext(request: ExtractSessionRequest): string {
364
416
  }
365
417
 
366
418
  function sessionExtractionPrompt(request: ExtractSessionRequest): string {
419
+ const allowedKinds = allowedExtractionKinds(request.actors.length);
420
+ const allowsPreference = allowedKinds.has("preference");
367
421
  return [
368
422
  "<memory-extraction-input>",
369
423
  "Extract durable memories from this completed agent run using the runtime-owned context below.",
@@ -374,12 +428,21 @@ function sessionExtractionPrompt(request: ExtractSessionRequest): string {
374
428
  "",
375
429
  existingMemoriesContext(request),
376
430
  "",
377
- memoryKindsContext(),
431
+ memoryKindsContext(allowedKinds),
378
432
  "",
379
433
  runTranscriptContext(request),
380
434
  "",
381
435
  "<rules>",
382
436
  "- Return at most five memories.",
437
+ "- Every returned memory must cite one or more evidenceMessageIndices from <run-transcript>.",
438
+ "- Cite only indices that directly support the stored fact; do not cite assistant messages as independent evidence.",
439
+ "- Each transcript message exposes authority (instruction or context), is_run_actor, and an actor id. Use these to classify evidence.",
440
+ ...(allowsPreference
441
+ ? [
442
+ "- For a preference, cite only messages with authority=instruction and is_run_actor=true; a preference must be the run actor's own first-person fact.",
443
+ ]
444
+ : []),
445
+ "- For a procedure or knowledge memory, cite run-actor instruction messages, public context messages, or successful tool results.",
383
446
  "- Use user messages and successful tool results as source evidence for storable facts.",
384
447
  "- Use failed tool results only when the failure reveals durable process knowledge, not transient errors.",
385
448
  "- Use assistant messages only as context; do not store the assistant's claims unless supported by user messages or tool results.",
@@ -391,9 +454,20 @@ function sessionExtractionPrompt(request: ExtractSessionRequest): string {
391
454
  "- A user question asking how, what, where, or whether to do something is not source evidence for the answer. Store the answer only when supported by a user-authored factual statement or a tool result.",
392
455
  "- Set kind=procedure for reusable task/process/runbook instructions.",
393
456
  "- Set kind=knowledge for shared team, project, channel, runbook, or operational facts.",
394
- "- Set kind=preference only for clear durable first-person facts authored by the current requester about their own preference, opinion, habit, identity, or workflow.",
395
- "- Reject named third-person personal facts such as another person's preference, opinion, habit, identity, relationship, or workflow. Do not assume a named person is the current requester.",
396
- "- User-authored task instructions are procedures, not preferences, unless they explicitly describe the requester's personal preference or habit.",
457
+ ...(allowsPreference
458
+ ? [
459
+ "- Set kind=preference only for clear durable first-person facts authored by the current actor about their own preference, opinion, habit, identity, or workflow.",
460
+ "- A single task request or ask-for-help is never a durable preference, even when phrased as an ongoing action for this run (for example 'help me capture takeaways as we go'). Do not convert a one-off ask into a 'Prefers ...' memory.",
461
+ "- A durable preference requires explicitly stated, generalizable first-person phrasing such as 'I prefer ...', 'I always ...', or 'I never ...' that describes how the actor wants things done in general, not just for the current task.",
462
+ ]
463
+ : [
464
+ "- This completed run has multiple run actors. Return only conversation-scoped procedure or knowledge memories.",
465
+ "- Do not return personal preferences, opinions, habits, identity facts, or workflow preferences from any actor in this run.",
466
+ "- Do not convert a personal first-person statement into shared knowledge or procedure. Statements like 'I prefer ...', 'I use ...', 'I always ...', or 'I never ...' are not memory evidence in this run.",
467
+ "- Shared team, channel, repository, or operational norms are eligible only when the source states them as collective practice or durable operational fact, not as one individual's preference.",
468
+ ]),
469
+ "- Reject named third-person personal facts such as another person's preference, opinion, habit, identity, relationship, or workflow. Do not assume a named person is the current actor.",
470
+ "- User-authored task instructions are procedures, not preferences, unless they explicitly describe the actor's personal preference or habit.",
397
471
  "- Procedural statements such as 'for X, do Y', 'when X, do Y', and 'to accomplish X, do Y' belong in procedures.",
398
472
  ...CANONICAL_CONTENT_RULES,
399
473
  "- Skip a candidate when existing-memories already cover the same durable fact.",
@@ -505,6 +579,7 @@ function extractedMemoriesFromResponse(
505
579
  content: memory.canonicalFact,
506
580
  expiresAtMs: memory.expiresAtMs,
507
581
  kind: memory.kind,
582
+ evidenceMessageIndices: memory.evidenceMessageIndices,
508
583
  });
509
584
  return response.memories.map(toMemory);
510
585
  }
package/src/plugin.ts CHANGED
@@ -15,7 +15,8 @@ import { createMemoryPromptMessages } from "./recall";
15
15
  import type { MemoryDb } from "./store";
16
16
 
17
17
  const MEMORY_MODEL_ENV = "AI_MEMORY_MODEL";
18
- const MEMORY_RECALL_MAX_VECTOR_DISTANCE_ENV = "MEMORY_RECALL_MAX_VECTOR_DISTANCE";
18
+ const MEMORY_RECALL_MAX_VECTOR_DISTANCE_ENV =
19
+ "MEMORY_RECALL_MAX_VECTOR_DISTANCE";
19
20
  const DEFAULT_RECALL_MAX_VECTOR_DISTANCE = 0.45;
20
21
 
21
22
  export interface MemoryPluginOptions {
@@ -52,14 +53,14 @@ function memoryToolContext(ctx: {
52
53
  conversationId?: string;
53
54
  db: MemoryToolContext["db"];
54
55
  embedder?: MemoryToolContext["embedder"];
55
- requester?: MemoryToolContext["requester"];
56
+ actor?: MemoryToolContext["actor"];
56
57
  source: MemoryToolContext["source"];
57
58
  userText?: string;
58
59
  }): MemoryToolContext {
59
60
  return {
60
61
  agent: ctx.agent,
61
62
  ...(ctx.conversationId ? { conversationId: ctx.conversationId } : {}),
62
- ...(ctx.requester ? { requester: ctx.requester } : {}),
63
+ ...(ctx.actor ? { actor: ctx.actor } : {}),
63
64
  db: ctx.db,
64
65
  ...(ctx.embedder ? { embedder: ctx.embedder } : {}),
65
66
  source: ctx.source,
@@ -72,7 +73,7 @@ function memoryCreateToolContext(ctx: {
72
73
  conversationId?: string;
73
74
  db: MemoryCreateToolContext["db"];
74
75
  embedder?: MemoryCreateToolContext["embedder"];
75
- requester?: MemoryCreateToolContext["requester"];
76
+ actor?: MemoryCreateToolContext["actor"];
76
77
  source: MemoryCreateToolContext["source"];
77
78
  supersessionDecider: MemoryCreateToolContext["supersessionDecider"];
78
79
  userText?: string;
@@ -133,7 +134,7 @@ export function createMemoryPlugin(options: MemoryPluginOptions = {}) {
133
134
  async userPrompt(ctx) {
134
135
  return await createMemoryPromptMessages({
135
136
  ...(ctx.conversationId ? { conversationId: ctx.conversationId } : {}),
136
- ...(ctx.requester ? { requester: ctx.requester } : {}),
137
+ ...(ctx.actor ? { actor: ctx.actor } : {}),
137
138
  db: ctx.db as MemoryDb,
138
139
  embedder: ctx.embedder,
139
140
  maxVectorDistance: recallMaxVectorDistance(options),
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import {
3
3
  getSourceKey,
4
4
  isPrivateSource,
5
+ type PluginRunContext,
6
+ type PluginRunTranscriptEntry,
5
7
  type PluginTaskContext,
6
8
  } from "@sentry/junior-plugin-api";
7
9
  import { z } from "zod";
@@ -15,11 +17,7 @@ import {
15
17
  parseExtractedMemory,
16
18
  type ExtractedMemory,
17
19
  } from "./agent";
18
- import {
19
- MEMORY_KINDS,
20
- memoryRuntimeContextSchema,
21
- type MemoryKind,
22
- } from "./types";
20
+ import { MEMORY_KINDS, memoryRuntimeContextSchema } from "./types";
23
21
 
24
22
  const MEMORY_TOOL_NAMES = new Set([
25
23
  "createMemory",
@@ -34,21 +32,111 @@ const extractedMemoryCacheSchema = z.array(
34
32
  content: z.string().min(1),
35
33
  expiresAtMs: z.number().finite().nullable(),
36
34
  kind: z.enum(MEMORY_KINDS),
35
+ evidenceMessageIndices: z
36
+ .array(z.number().int().nonnegative())
37
+ .min(1)
38
+ .max(10),
37
39
  })
38
40
  .strict()
39
41
  .transform(parseExtractedMemory),
40
42
  );
41
43
 
42
- function targetForKind(kind: MemoryKind): "requester" | "conversation" {
43
- if (kind === "preference") {
44
- return "requester";
44
+ /** Where a passively extracted memory may be stored, or dropped when unproven. */
45
+ type MemoryRouteTarget = "drop" | "personal" | "conversation";
46
+
47
+ /** A cited entry is a run-actor durable instruction evidence entry. */
48
+ function isRunActorInstruction(entry: PluginRunTranscriptEntry): boolean {
49
+ return (
50
+ entry.type === "message" &&
51
+ entry.role === "user" &&
52
+ entry.provenance?.authority === "instruction" &&
53
+ entry.isRunActor === true
54
+ );
55
+ }
56
+
57
+ /** A cited entry is valid public conversation evidence for shared knowledge. */
58
+ function isConversationEvidence(entry: PluginRunTranscriptEntry): boolean {
59
+ if (entry.type === "toolResult") {
60
+ return entry.isError === false && Boolean(entry.text?.trim());
61
+ }
62
+ if (
63
+ entry.type === "message" &&
64
+ entry.role === "user" &&
65
+ entry.provenance?.authority === "instruction" &&
66
+ entry.isRunActor === false
67
+ ) {
68
+ return Boolean(entry.provenance.actor);
69
+ }
70
+ return (
71
+ entry.type === "message" &&
72
+ entry.role === "user" &&
73
+ entry.provenance?.authority === "context"
74
+ );
75
+ }
76
+
77
+ /** Resolve the deduplicated cited transcript entries, failing on bad indices. */
78
+ function citedEntries(
79
+ indices: number[],
80
+ transcript: PluginRunTranscriptEntry[],
81
+ ): { valid: boolean; entries: PluginRunTranscriptEntry[] } {
82
+ const seen = new Set<number>();
83
+ const entries: PluginRunTranscriptEntry[] = [];
84
+ for (const index of indices) {
85
+ if (seen.has(index)) {
86
+ continue;
87
+ }
88
+ seen.add(index);
89
+ const entry = transcript[index];
90
+ if (!entry) {
91
+ return { valid: false, entries: [] };
92
+ }
93
+ entries.push(entry);
45
94
  }
46
- return "conversation";
95
+ return { valid: entries.length > 0, entries };
47
96
  }
48
97
 
49
- function memoryIdempotencySuffix(memory: ExtractedMemory): string {
98
+ /**
99
+ * Verify an extracted memory against runtime-owned provenance on its cited
100
+ * evidence. This is a deterministic authority boundary, not a model decision:
101
+ * personal preferences require a single-actor run whose citations are all
102
+ * run-actor instructions, conversation knowledge requires run-actor instruction
103
+ * or valid public conversation evidence, and anything unproven (including
104
+ * missing provenance) is dropped. Multi-actor runs interleave first-person
105
+ * statements from different people, so they never store a preference regardless
106
+ * of citations; a personal preference can wait for a single-actor run.
107
+ */
108
+ function routeExtractedMemory(
109
+ memory: ExtractedMemory,
110
+ transcript: PluginRunTranscriptEntry[],
111
+ run: Pick<PluginRunContext, "actor" | "actors">,
112
+ ): MemoryRouteTarget {
113
+ const cited = citedEntries(memory.evidenceMessageIndices, transcript);
114
+ if (!cited.valid) {
115
+ return "drop";
116
+ }
117
+ if (memory.kind === "preference") {
118
+ // Only a run attributed to exactly one run actor may store a preference; an
119
+ // empty actor set (system runs) fails closed to "drop".
120
+ const exactlyOneRunActor = Boolean(run.actor) && run.actors.length === 1;
121
+ if (!exactlyOneRunActor) {
122
+ return "drop";
123
+ }
124
+ // Never downgrade an unproven first-person preference to conversation scope.
125
+ return cited.entries.every(isRunActorInstruction) ? "personal" : "drop";
126
+ }
127
+ return cited.entries.every(
128
+ (entry) => isRunActorInstruction(entry) || isConversationEvidence(entry),
129
+ )
130
+ ? "conversation"
131
+ : "drop";
132
+ }
133
+
134
+ function memoryIdempotencySuffix(
135
+ memory: ExtractedMemory,
136
+ target: MemoryRouteTarget,
137
+ ): string {
50
138
  return createHash("sha256")
51
- .update(targetForKind(memory.kind))
139
+ .update(target)
52
140
  .update("\0")
53
141
  .update(memory.kind)
54
142
  .update("\0")
@@ -63,10 +151,11 @@ function passiveInput(
63
151
  sessionId: string,
64
152
  memory: ExtractedMemory,
65
153
  sourceKey: string,
154
+ target: MemoryRouteTarget,
66
155
  ): CreateMemoryInput {
67
156
  return {
68
157
  content: memory.content,
69
- idempotencyKey: `session:${sourceKey}:${sessionId}:${memoryIdempotencySuffix(memory)}`,
158
+ idempotencyKey: `session:${sourceKey}:${sessionId}:${memoryIdempotencySuffix(memory, target)}`,
70
159
  kind: memory.kind,
71
160
  ...(memory.expiresAtMs !== null ? { expiresAtMs: memory.expiresAtMs } : {}),
72
161
  };
@@ -79,7 +168,11 @@ async function getTaskMemories(
79
168
  const cacheKey = `memory-extraction:${context.id}`;
80
169
  const cached = await context.state.get(cacheKey);
81
170
  if (cached !== undefined) {
82
- return extractedMemoryCacheSchema.parse(cached);
171
+ const parsed = extractedMemoryCacheSchema.safeParse(cached);
172
+ if (parsed.success) {
173
+ return parsed.data;
174
+ }
175
+ await context.state.delete(cacheKey);
83
176
  }
84
177
  const memories = await extract();
85
178
  if (memories.length > 0) {
@@ -132,7 +225,7 @@ export async function processMemorySession(
132
225
 
133
226
  const runtimeContext = memoryRuntimeContextSchema.parse({
134
227
  conversationId: run.conversationId,
135
- ...(run.requester ? { requester: run.requester } : {}),
228
+ ...(run.actor ? { actor: run.actor } : {}),
136
229
  source: run.source,
137
230
  });
138
231
  const agent = createMemoryAgent(context.model);
@@ -150,6 +243,7 @@ export async function processMemorySession(
150
243
  existingMemories: existingMemories.map((memory) => ({
151
244
  content: memory.content,
152
245
  })),
246
+ actors: run.actors,
153
247
  transcript,
154
248
  runtimeContext,
155
249
  });
@@ -159,12 +253,16 @@ export async function processMemorySession(
159
253
  }
160
254
 
161
255
  for (const memory of memories) {
162
- const input = passiveInput(run.runId, memory, sourceKey);
163
- if (targetForKind(memory.kind) === "conversation") {
164
- await store.createConversationMemory(input);
256
+ // The routing gate stays even though extraction is also actor-gated:
257
+ // getTaskMemories caches extraction output for 7 days, so a retry can replay
258
+ // preference proposals cached before this gate existed.
259
+ const target = routeExtractedMemory(memory, transcript, run);
260
+ if (target === "drop") {
165
261
  continue;
166
262
  }
167
- if (!run.requester) {
263
+ const input = passiveInput(run.runId, memory, sourceKey, target);
264
+ if (target === "conversation") {
265
+ await store.createConversationMemory(input);
168
266
  continue;
169
267
  }
170
268
  await store.createMemory(input);