auggy 0.3.0

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.
Files changed (121) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/README.md +161 -0
  4. package/package.json +76 -0
  5. package/src/agent-card.ts +39 -0
  6. package/src/agent.ts +283 -0
  7. package/src/agentmail-client.ts +138 -0
  8. package/src/augments/bash/index.ts +463 -0
  9. package/src/augments/bash/skill/SKILL.md +156 -0
  10. package/src/augments/budgets/budget-store.ts +513 -0
  11. package/src/augments/budgets/index.ts +134 -0
  12. package/src/augments/budgets/preamble.ts +93 -0
  13. package/src/augments/budgets/types.ts +89 -0
  14. package/src/augments/file-memory/index.ts +71 -0
  15. package/src/augments/filesystem/index.ts +533 -0
  16. package/src/augments/filesystem/skill/SKILL.md +142 -0
  17. package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
  18. package/src/augments/layered-memory/extractor/buffer.ts +56 -0
  19. package/src/augments/layered-memory/extractor/frequency.ts +79 -0
  20. package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
  21. package/src/augments/layered-memory/extractor/parse.ts +75 -0
  22. package/src/augments/layered-memory/extractor/prompt.md +26 -0
  23. package/src/augments/layered-memory/index.ts +757 -0
  24. package/src/augments/layered-memory/skill/SKILL.md +153 -0
  25. package/src/augments/layered-memory/storage/migrations/README.md +16 -0
  26. package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
  27. package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
  28. package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
  29. package/src/augments/layered-memory/storage/types.ts +98 -0
  30. package/src/augments/link/index.ts +489 -0
  31. package/src/augments/link/translate.ts +261 -0
  32. package/src/augments/notify/adapters/agentmail.ts +70 -0
  33. package/src/augments/notify/adapters/telegram.ts +60 -0
  34. package/src/augments/notify/adapters/webhook.ts +55 -0
  35. package/src/augments/notify/index.ts +284 -0
  36. package/src/augments/notify/skill/SKILL.md +150 -0
  37. package/src/augments/org-context/index.ts +721 -0
  38. package/src/augments/org-context/skill/SKILL.md +96 -0
  39. package/src/augments/skills/index.ts +103 -0
  40. package/src/augments/supabase-memory/index.ts +151 -0
  41. package/src/augments/telegram-transport/index.ts +312 -0
  42. package/src/augments/telegram-transport/polling.ts +55 -0
  43. package/src/augments/telegram-transport/webhook.ts +56 -0
  44. package/src/augments/turn-control/index.ts +61 -0
  45. package/src/augments/turn-control/skill/SKILL.md +155 -0
  46. package/src/augments/visitor-auth/email-validation.ts +66 -0
  47. package/src/augments/visitor-auth/index.ts +779 -0
  48. package/src/augments/visitor-auth/rate-limiter.ts +90 -0
  49. package/src/augments/visitor-auth/skill/SKILL.md +55 -0
  50. package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
  51. package/src/augments/visitor-auth/storage/types.ts +164 -0
  52. package/src/augments/visitor-auth/types.ts +123 -0
  53. package/src/augments/visitor-auth/verify-page.ts +179 -0
  54. package/src/augments/web-fetch/index.ts +331 -0
  55. package/src/augments/web-fetch/skill/SKILL.md +100 -0
  56. package/src/cli/agent-index.ts +289 -0
  57. package/src/cli/augment-catalog.ts +320 -0
  58. package/src/cli/augment-resolver.ts +597 -0
  59. package/src/cli/commands/add-skill.ts +194 -0
  60. package/src/cli/commands/add.ts +87 -0
  61. package/src/cli/commands/chat.ts +207 -0
  62. package/src/cli/commands/create.ts +462 -0
  63. package/src/cli/commands/dev.ts +139 -0
  64. package/src/cli/commands/eval.ts +180 -0
  65. package/src/cli/commands/ls.ts +66 -0
  66. package/src/cli/commands/remove.ts +95 -0
  67. package/src/cli/commands/restart.ts +40 -0
  68. package/src/cli/commands/start.ts +123 -0
  69. package/src/cli/commands/status.ts +104 -0
  70. package/src/cli/commands/stop.ts +84 -0
  71. package/src/cli/commands/visitors-revoke.ts +155 -0
  72. package/src/cli/commands/visitors.ts +101 -0
  73. package/src/cli/config-parser.ts +1034 -0
  74. package/src/cli/engine-resolver.ts +68 -0
  75. package/src/cli/index.ts +178 -0
  76. package/src/cli/model-picker.ts +89 -0
  77. package/src/cli/pid-registry.ts +146 -0
  78. package/src/cli/plist-generator.ts +117 -0
  79. package/src/cli/resolve-config.ts +56 -0
  80. package/src/cli/scaffold-skills.ts +158 -0
  81. package/src/cli/scaffold.ts +291 -0
  82. package/src/cli/skill-frontmatter.ts +51 -0
  83. package/src/cli/skill-validator.ts +151 -0
  84. package/src/cli/types.ts +228 -0
  85. package/src/cli/yaml-helpers.ts +66 -0
  86. package/src/engines/_shared/cost.ts +55 -0
  87. package/src/engines/_shared/schema-normalize.ts +75 -0
  88. package/src/engines/anthropic/pricing.ts +117 -0
  89. package/src/engines/anthropic.ts +483 -0
  90. package/src/engines/openai/pricing.ts +67 -0
  91. package/src/engines/openai.ts +446 -0
  92. package/src/engines/openrouter/pricing.ts +83 -0
  93. package/src/engines/openrouter.ts +185 -0
  94. package/src/helpers.ts +24 -0
  95. package/src/http.ts +387 -0
  96. package/src/index.ts +165 -0
  97. package/src/kernel/capability-table.ts +172 -0
  98. package/src/kernel/context-allocator.ts +161 -0
  99. package/src/kernel/history-manager.ts +198 -0
  100. package/src/kernel/lifecycle-manager.ts +106 -0
  101. package/src/kernel/output-validator.ts +35 -0
  102. package/src/kernel/preamble.ts +23 -0
  103. package/src/kernel/route-collector.ts +97 -0
  104. package/src/kernel/timeout.ts +21 -0
  105. package/src/kernel/tool-selector.ts +47 -0
  106. package/src/kernel/trace-emitter.ts +66 -0
  107. package/src/kernel/transport-queue.ts +147 -0
  108. package/src/kernel/turn-loop.ts +1148 -0
  109. package/src/memory/context-synthesis.ts +83 -0
  110. package/src/memory/memory-bus.ts +61 -0
  111. package/src/memory/registry.ts +80 -0
  112. package/src/memory/tools.ts +320 -0
  113. package/src/memory/types.ts +8 -0
  114. package/src/parts.ts +30 -0
  115. package/src/scaffold-templates/identity.md +31 -0
  116. package/src/telegram-client.ts +145 -0
  117. package/src/tokenizer.ts +14 -0
  118. package/src/transports/ag-ui-events.ts +253 -0
  119. package/src/transports/visitor-token.ts +82 -0
  120. package/src/transports/web-transport.ts +948 -0
  121. package/src/types.ts +1009 -0
@@ -0,0 +1,757 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type {
4
+ Augment,
5
+ CostResult,
6
+ InternalTurnContext,
7
+ MemoryEntry,
8
+ MemoryQueryOpts,
9
+ MemoryWriteOpts,
10
+ SchedulerContext,
11
+ Transcript,
12
+ TurnResult,
13
+ TurnTrigger,
14
+ } from "../../types";
15
+ import { createBuffer, type ExtractionBuffer } from "./extractor/buffer";
16
+ import { type ExtractionFrequencyConfig, shouldExtract } from "./extractor/frequency";
17
+ import { type ExtractionEngine, handleExtractionTurn } from "./extractor/inject-handler";
18
+ import { createSqliteStore } from "./storage/sqlite-store";
19
+ import { createSupabaseStore, type LayeredSupabaseClient } from "./storage/supabase-store";
20
+ import type { MemoryStore, StoreEntry } from "./storage/types";
21
+
22
+ /**
23
+ * Optional auto-save block (PR β / ADR-018 Phase 2). When `enabled` is
24
+ * true (the default), the augment registers two hooks per ADR-027:
25
+ *
26
+ * - `scheduleAfterTurn`: fires after every user-facing turn, runs the
27
+ * per-trust-level frequency dispatcher, and either skips, buffers,
28
+ * or admits an internal extraction turn via `ctx.inject(...)`.
29
+ * - `handleInternalTurn`: claims triggers whose `source ===
30
+ * "layered-memory.autoSave"` and runs the extraction LLM call inside
31
+ * the admitted turn, so cost flows through the standard turn-loop
32
+ * machinery (turn-gate prepare/confirm + commit) — closing the
33
+ * cost-attribution gap Codex Critical-2 flagged. The handler returns
34
+ * a TurnResult whose `trace.inferenceSteps[]` carries the priced
35
+ * cost the engine reported; `runCostCommit` aggregates and the
36
+ * budgets augment commits identically to a user-facing turn.
37
+ */
38
+ export interface LayeredMemoryAutoSaveOptions {
39
+ enabled?: boolean;
40
+ extractionFrequency?: ExtractionFrequencyConfig;
41
+ everyNTurns?: number;
42
+ confidenceThreshold?: number;
43
+ /**
44
+ * Operator-supplied path to a custom extraction prompt template. The
45
+ * template must contain a `{{TRANSCRIPT}}` token the handler replaces
46
+ * with the rendered transcript. When absent, the bundled
47
+ * `extractor/prompt.md` ships as the default.
48
+ */
49
+ promptTemplate?: string;
50
+ /**
51
+ * Dedicated extraction engine. Required for auto-save to perform
52
+ * extraction. Memorist Decision 6 anticipates this engine being a
53
+ * cheaper Haiku-priced adapter while the user-facing agent runs on
54
+ * Sonnet — keep this knob explicit so operators don't accidentally
55
+ * route extraction through the (more expensive) primary engine.
56
+ */
57
+ engine?: ExtractionEngine;
58
+ }
59
+
60
+ export interface LayeredMemoryOptions {
61
+ backend: "sqlite" | "supabase";
62
+ namespace: string;
63
+ retentionDays?: number;
64
+ // SQLite-specific
65
+ dbPath?: string;
66
+ // Supabase-specific
67
+ client?: LayeredSupabaseClient;
68
+ table?: string;
69
+ // PR β
70
+ autoSave?: LayeredMemoryAutoSaveOptions;
71
+ }
72
+
73
+ /**
74
+ * Trigger source string used by both the auto-save scheduler and the
75
+ * internal-turn handler. Kept as a constant so the dispatch routing
76
+ * key has exactly one definition site.
77
+ */
78
+ const AUTO_SAVE_TRIGGER_SOURCE = "layered-memory.autoSave";
79
+
80
+ /**
81
+ * Default per-trust-level frequency config — Decision 3 of the memorist
82
+ * design with Codex 2nd-pass Important-2 calibration applied (`agent`
83
+ * defaults to `every-N-turns` rather than `every-turn`).
84
+ */
85
+ const DEFAULT_FREQUENCY_CONFIG: ExtractionFrequencyConfig = {
86
+ creator: "every-turn",
87
+ agent: "every-N-turns",
88
+ public: { recognized: "every-turn", anonymous: "session-end-only" },
89
+ };
90
+
91
+ const DEFAULT_EVERY_N_TURNS = 3;
92
+ const DEFAULT_CONFIDENCE_THRESHOLD = 0.5;
93
+
94
+ /**
95
+ * Synthetic model label that surfaces in the trace's inference-step
96
+ * record for an extraction turn. Distinct from the user-facing agent's
97
+ * model so trace consumers can differentiate extraction spend from
98
+ * primary spend.
99
+ */
100
+ const EXTRACTION_MODEL_LABEL = "layered-memory.extraction-engine";
101
+
102
+ function storeEntryToMemoryEntry(e: StoreEntry): MemoryEntry {
103
+ return {
104
+ label: e.label,
105
+ content: e.content,
106
+ peerId: e.peerId ?? undefined,
107
+ trustLevel: e.trustLevel ?? undefined,
108
+ createdAt: e.createdAt,
109
+ supersededBy: e.supersededBy ?? undefined,
110
+ retentionClass: e.retentionClass,
111
+ isVerbatim: e.isVerbatim,
112
+ origin: e.origin,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Load the extraction prompt template — operator override path takes
118
+ * precedence; otherwise the augment's bundled `extractor/prompt.md`.
119
+ * Returns null if neither is readable; auto-save then logs and stays
120
+ * disabled at boot rather than failing the whole augment.
121
+ */
122
+ function loadPromptTemplate(overridePath?: string): string | null {
123
+ if (overridePath && existsSync(overridePath)) {
124
+ try {
125
+ return readFileSync(overridePath, "utf-8");
126
+ } catch (err) {
127
+ console.warn(
128
+ `[layered-memory.autoSave] failed to read promptTemplate "${overridePath}": ${(err as Error).message}`,
129
+ );
130
+ return null;
131
+ }
132
+ }
133
+ // Bundled default — sibling file inside the extractor folder.
134
+ const bundled = join(import.meta.dir, "extractor", "prompt.md");
135
+ if (existsSync(bundled)) {
136
+ try {
137
+ return readFileSync(bundled, "utf-8");
138
+ } catch (err) {
139
+ console.warn(
140
+ `[layered-memory.autoSave] failed to read bundled prompt template: ${(err as Error).message}`,
141
+ );
142
+ return null;
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Detect that a TurnResult corresponds to an auto-save extraction turn
150
+ * the augment itself injected. Used as the recursion guard so the
151
+ * scheduleAfterTurn hook (which fires on EVERY turn, including the
152
+ * extraction turn the augment injected) does not re-enter and trigger
153
+ * extraction-on-extraction loops.
154
+ */
155
+ function isAutoSaveTurn(result: TurnResult): boolean {
156
+ return (
157
+ result.trace.trigger.type === "internal" &&
158
+ result.trace.trigger.sourceAugment === AUTO_SAVE_TRIGGER_SOURCE
159
+ );
160
+ }
161
+
162
+ /**
163
+ * Build the per-peer namespaced label used for an extracted fact. The
164
+ * shape mirrors PR α's peer-scoped discipline: `<prefix><peerId>:<turnId>:<idx>`.
165
+ * Each fact gets a distinct suffix so they don't collide within a turn.
166
+ */
167
+ function buildAutoSaveLabel(
168
+ prefix: string,
169
+ peerId: string,
170
+ sourceTurnId: string,
171
+ factIndex: number,
172
+ ): string {
173
+ return `${prefix}${peerId}:${sourceTurnId}:${factIndex}`;
174
+ }
175
+
176
+ /**
177
+ * Payload shape carried on the internal trigger from `scheduleAfterTurn`
178
+ * to `handleInternalTurn`. Kept structural (no class) so the kernel's
179
+ * generic `TurnTrigger.payload: Record<string, unknown>` type accepts it.
180
+ */
181
+ interface AutoSaveTriggerPayload extends Record<string, unknown> {
182
+ transcript: Transcript;
183
+ sourceTurnId: string;
184
+ promptTemplate: string;
185
+ confidenceThreshold: number;
186
+ prefix: string;
187
+ peerId: string;
188
+ }
189
+
190
+ function isAutoSaveTriggerPayload(
191
+ payload: TurnTrigger["payload"],
192
+ ): payload is AutoSaveTriggerPayload {
193
+ if (!payload || typeof payload !== "object") return false;
194
+ const p = payload as Record<string, unknown>;
195
+ return (
196
+ typeof p.sourceTurnId === "string" &&
197
+ typeof p.promptTemplate === "string" &&
198
+ typeof p.confidenceThreshold === "number" &&
199
+ typeof p.prefix === "string" &&
200
+ typeof p.peerId === "string" &&
201
+ p.transcript !== undefined
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Build the TurnResult the kernel turn-loop folds into the kernel
207
+ * trace. The `trace.inferenceSteps[]` carries the priced (or unpriced)
208
+ * extraction cost; the kernel's runCostCommit aggregates this and
209
+ * turnGate.commit observes it (this is the cost-flow path that closes
210
+ * Codex Critical-2).
211
+ *
212
+ * The trace is a stub — the kernel preserves its own trace fields
213
+ * (turnId, threadId, trigger metadata, timestamps) and only consumes
214
+ * `inferenceSteps[]` from the handler-returned trace. We populate the
215
+ * other trace fields with zero/empty defaults so the type checks.
216
+ */
217
+ function buildExtractionTurnResult(args: {
218
+ trigger: TurnTrigger;
219
+ status: "completed" | "failed";
220
+ cost: CostResult;
221
+ inferenceDurationMs: number;
222
+ inputTokens?: number;
223
+ outputTokens?: number;
224
+ errorMessage?: string;
225
+ responseText?: string;
226
+ }): TurnResult {
227
+ const turnId = args.trigger.turnId;
228
+ const threadId = args.trigger.threadId ?? turnId;
229
+ return {
230
+ turnId,
231
+ success: args.status === "completed",
232
+ status: args.status,
233
+ response: args.responseText
234
+ ? { parts: [{ kind: "text", text: args.responseText }] }
235
+ : undefined,
236
+ toolCalls: [],
237
+ error: args.errorMessage
238
+ ? { message: args.errorMessage, source: AUTO_SAVE_TRIGGER_SOURCE }
239
+ : undefined,
240
+ trace: {
241
+ turnId,
242
+ threadId,
243
+ timestamp: Date.now(),
244
+ duration: 0,
245
+ trigger: {
246
+ type: "internal",
247
+ sourceAugment: AUTO_SAVE_TRIGGER_SOURCE,
248
+ },
249
+ contextAssembly: {
250
+ augmentBlocks: [],
251
+ preambleTokens: 0,
252
+ toolSchemaTokens: 0,
253
+ historyTokens: 0,
254
+ totalTokens: 0,
255
+ budgetUsed: 0,
256
+ },
257
+ toolSelection: {
258
+ totalTools: 0,
259
+ phase1Used: false,
260
+ mountedTools: [],
261
+ withheldTools: [],
262
+ },
263
+ inferenceSteps: [
264
+ {
265
+ model: EXTRACTION_MODEL_LABEL,
266
+ inputTokens: args.inputTokens ?? 0,
267
+ outputTokens: args.outputTokens ?? 0,
268
+ durationMs: args.inferenceDurationMs,
269
+ toolCalls: [],
270
+ cost: args.cost,
271
+ },
272
+ ],
273
+ capabilityChecks: [],
274
+ },
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Run the extraction body for a single internal turn. Calls the engine,
280
+ * parses the response, writes facts via the store's writeAutoSavedEntry,
281
+ * and returns a TurnResult whose trace.inferenceSteps[] carries the
282
+ * priced cost the kernel turn-loop's runCostCommit will surface to
283
+ * turnGate.commit (cost-flow contract per ADR-027 Decision 5).
284
+ *
285
+ * Best-effort. Engine errors and parse errors map to a failed
286
+ * TurnResult that STILL carries the engine's reported cost (when an
287
+ * engine billed for a malformed response, suppressing it in budgets
288
+ * would silently break daily-cap accounting). Per-fact write failures
289
+ * are logged and swallowed.
290
+ */
291
+ async function runExtractionInsideTurn(args: {
292
+ trigger: TurnTrigger;
293
+ transcript: Transcript;
294
+ engine: ExtractionEngine;
295
+ promptTemplate: string;
296
+ store: MemoryStore;
297
+ prefix: string;
298
+ peerId: string;
299
+ confidenceThreshold: number;
300
+ sourceTurnId: string;
301
+ }): Promise<TurnResult> {
302
+ const inferenceStart = Date.now();
303
+ const result = await handleExtractionTurn({
304
+ transcript: args.transcript,
305
+ engine: args.engine,
306
+ promptTemplate: args.promptTemplate,
307
+ });
308
+ const inferenceDurationMs = Date.now() - inferenceStart;
309
+ const cost: CostResult =
310
+ result.costUsd > 0
311
+ ? { priced: true, costUsd: result.costUsd }
312
+ : { priced: false, reason: "extraction engine reported zero cost" };
313
+
314
+ if (!result.success) {
315
+ console.warn(
316
+ `[layered-memory.autoSave] extraction failed (sourceTurn=${args.sourceTurnId}): ${result.error}`,
317
+ );
318
+ return buildExtractionTurnResult({
319
+ trigger: args.trigger,
320
+ status: "failed",
321
+ cost,
322
+ inferenceDurationMs,
323
+ errorMessage: result.error,
324
+ });
325
+ }
326
+
327
+ let written = 0;
328
+ for (const [idx, fact] of result.facts.entries()) {
329
+ if (fact.confidence < args.confidenceThreshold) {
330
+ // Below threshold: skip rather than write a low-signal entry.
331
+ // Spec Decision 7 leaves a knob for "write but flag" — at v1.0
332
+ // we err on the side of fewer writes; future calibration can
333
+ // revisit once eval data exists.
334
+ continue;
335
+ }
336
+ try {
337
+ await args.store.writeAutoSavedEntry({
338
+ peerId: args.peerId,
339
+ label: buildAutoSaveLabel(args.prefix, args.peerId, args.sourceTurnId, idx),
340
+ content: fact.object,
341
+ subject: fact.subject,
342
+ predicate: fact.predicate,
343
+ object: fact.object,
344
+ confidence: fact.confidence,
345
+ retentionClass: "operational",
346
+ isVerbatim: fact.isVerbatim,
347
+ sourceTurnId: args.sourceTurnId,
348
+ model: EXTRACTION_MODEL_LABEL,
349
+ });
350
+ written++;
351
+ } catch (err) {
352
+ console.warn(
353
+ `[layered-memory.autoSave] writeAutoSavedEntry failed for fact ${idx}: ${(err as Error).message}`,
354
+ );
355
+ }
356
+ }
357
+
358
+ return buildExtractionTurnResult({
359
+ trigger: args.trigger,
360
+ status: "completed",
361
+ cost,
362
+ inferenceDurationMs,
363
+ responseText: `extracted ${written} fact(s) from turn ${args.sourceTurnId}`,
364
+ });
365
+ }
366
+
367
+ export async function layeredMemory(opts: LayeredMemoryOptions): Promise<Augment> {
368
+ const prefix = opts.namespace.endsWith(":") ? opts.namespace : `${opts.namespace}:`;
369
+ const retentionDays = opts.retentionDays ?? 90;
370
+
371
+ let store: MemoryStore;
372
+ if (opts.backend === "sqlite") {
373
+ if (!opts.dbPath) throw new Error("layeredMemory: sqlite backend requires dbPath");
374
+ store = createSqliteStore({
375
+ dbPath: opts.dbPath,
376
+ retentionDays,
377
+ namespace: opts.namespace,
378
+ });
379
+ } else if (opts.backend === "supabase") {
380
+ if (!opts.client || !opts.table) {
381
+ throw new Error("layeredMemory: supabase backend requires client and table");
382
+ }
383
+ store = createSupabaseStore({
384
+ client: opts.client,
385
+ table: opts.table,
386
+ retentionDays,
387
+ namespace: opts.namespace,
388
+ });
389
+ } else {
390
+ throw new Error(`layeredMemory: unknown backend "${opts.backend}"`);
391
+ }
392
+
393
+ await store.initialize();
394
+
395
+ // Auto-save state (per-augment-instance — process-local). The buffer
396
+ // accumulates session-end-only transcripts; turnIndexes drives the
397
+ // every-N-turns dispatcher; threadPeerHistory tracks the most-recent
398
+ // peerId observed on each threadId so the scheduler can detect an
399
+ // anonymous→recognized promotion (Decision 5 of the memorist design).
400
+ const autoSaveEnabled = opts.autoSave?.enabled ?? true;
401
+ const buffer: ExtractionBuffer = createBuffer();
402
+ const turnIndexes = new Map<string, number>();
403
+ const threadPeerHistory = new Map<string, string>();
404
+ const promptTemplate = autoSaveEnabled ? loadPromptTemplate(opts.autoSave?.promptTemplate) : null;
405
+ if (autoSaveEnabled && promptTemplate === null) {
406
+ console.warn(
407
+ "[layered-memory.autoSave] no prompt template available; auto-save disabled until promptTemplate is configured",
408
+ );
409
+ }
410
+ const frequencyConfig = opts.autoSave?.extractionFrequency ?? DEFAULT_FREQUENCY_CONFIG;
411
+ const everyNTurns = opts.autoSave?.everyNTurns ?? DEFAULT_EVERY_N_TURNS;
412
+ const confidenceThreshold = opts.autoSave?.confidenceThreshold ?? DEFAULT_CONFIDENCE_THRESHOLD;
413
+ const extractionEngine = opts.autoSave?.engine;
414
+
415
+ const search = async (query: string, queryOpts?: MemoryQueryOpts): Promise<MemoryEntry[]> => {
416
+ const results = await store.search(query, queryOpts?.peerId);
417
+ return results.map(storeEntryToMemoryEntry);
418
+ };
419
+
420
+ const write = async (
421
+ label: string,
422
+ content: string,
423
+ writeOpts?: MemoryWriteOpts,
424
+ ): Promise<void> => {
425
+ if (!label.startsWith(prefix)) {
426
+ throw new Error(
427
+ `layeredMemory: label "${label}" does not start with namespace prefix "${prefix}"`,
428
+ );
429
+ }
430
+ // Structural peer-binding: if a peerId is provided, the label MUST be
431
+ // scoped to that peer (format: <prefix><peerId> or <prefix><peerId>:<rest>).
432
+ // This prevents peer A from writing to a label like "ep:vis_b:1" — even if
433
+ // they guessed it — by storing a row whose label segment claims another
434
+ // peer. Without this, search remains peer-isolated (rows are stored with
435
+ // the caller's peer_id, not the label's), but the database accumulates
436
+ // misleading rows that could surface in audit/forget paths.
437
+ const peerId = writeOpts?.peerId;
438
+ if (peerId) {
439
+ const peerScopedPrefix = `${prefix}${peerId}`;
440
+ if (label !== peerScopedPrefix && !label.startsWith(`${peerScopedPrefix}:`)) {
441
+ throw new Error(
442
+ `layeredMemory: peer "${peerId}" cannot write to label "${label}" — labels must be scoped as "${peerScopedPrefix}" or "${peerScopedPrefix}:<topic>"`,
443
+ );
444
+ }
445
+ }
446
+
447
+ await store.write({
448
+ label,
449
+ content,
450
+ peerId: peerId ?? null,
451
+ trustLevel: writeOpts?.trustLevel ?? null,
452
+ createdAt: Date.now(),
453
+ supersededBy: null,
454
+ retentionClass: "operational",
455
+ isVerbatim: false,
456
+ expiresAt: null,
457
+ });
458
+ };
459
+
460
+ const forget = async (peerId: string): Promise<number> => {
461
+ return store.forget(peerId);
462
+ };
463
+
464
+ // NOTE: read() is intentionally NOT exposed on this NamespaceMemoryProvider.
465
+ // Episodic memory is peer-scoped — direct label reads bypass that scoping
466
+ // because the generic memory_read tool only checks origin, not peer
467
+ // ownership of the label. Callers must use search (peer-scoped via
468
+ // ToolExecuteContext) instead. memory_read on an "ep:" label will return
469
+ // "does not support reading by label", which is the desired behavior.
470
+
471
+ /**
472
+ * Detect that an anonymous→recognized promotion has just happened on
473
+ * `threadId` and, if so, flush the buffered anonymous-bound transcripts
474
+ * by injecting an extraction trigger. Per the post-PR-1 fix, the flush
475
+ * targets the NEW recognized peer-id (currentPeer), NOT the prior
476
+ * anonymous peer-id — this preserves visitorAuth's verify-time peer-id
477
+ * migration. Pragmatic deviation from the original "Decision 5" of the
478
+ * memorist design (which scoped facts to their original identity);
479
+ * once a visitor verifies, they own the conversation history they
480
+ * participated in. See inline comment at the payload construction
481
+ * site for full rationale.
482
+ *
483
+ * The trigger's `peer` field is also set to `currentPeer` (not the old
484
+ * anon peer) so that budget caps and turn gates apply to the recognized
485
+ * identity — preventing the anonymous peer's (possibly exhausted) caps
486
+ * from blocking the flush.
487
+ *
488
+ * The detection rule is unchanged:
489
+ * - the previously-observed peerId for this threadId is `anon-<threadId>`,
490
+ * - the current peerId is different, AND
491
+ * - there are buffered transcripts under the prior anonymous peerId.
492
+ *
493
+ * Best-effort. ctx.inject failures are caught and logged; the buffered
494
+ * transcripts are dropped on failure.
495
+ */
496
+ async function maybeFlushOnPromotion(
497
+ threadId: string,
498
+ currentPeer: import("../../types").PeerIdentity,
499
+ ctx: SchedulerContext,
500
+ ): Promise<void> {
501
+ const currentPeerId = currentPeer.id;
502
+ const priorPeerId = threadPeerHistory.get(threadId);
503
+ if (!priorPeerId) return; // first turn on this thread
504
+ if (priorPeerId === currentPeerId) return; // same peer, no promotion
505
+ if (priorPeerId !== `anon-${threadId}`) return; // not an anonymous→other transition
506
+ const buffered = buffer.flush(priorPeerId);
507
+ if (buffered.length === 0) return; // nothing to flush
508
+ if (!extractionEngine || !promptTemplate) return; // can't extract
509
+
510
+ // Synthesize a single combined transcript from the buffered turns
511
+ // so one extraction call covers the whole anonymous batch (per
512
+ // session-end-only semantics). The flush's source turnId is the
513
+ // last buffered turn's id — that's the most recent context the
514
+ // anonymous peer contributed before promotion.
515
+ const last = buffered[buffered.length - 1];
516
+ if (!last) return;
517
+ const combinedParts = buffered.flatMap((t) => t.parts);
518
+ // The buffered transcripts were recorded under the OLD anonymous identity —
519
+ // preserve that in the combined transcript (historical record of what
520
+ // the peer said while anonymous).
521
+ const combinedTranscript: Transcript = {
522
+ turnId: last.turnId,
523
+ threadId: last.threadId,
524
+ peer: last.peer,
525
+ parts: combinedParts,
526
+ toolCalls: buffered.flatMap((t) => t.toolCalls),
527
+ startedAt: buffered[0]?.startedAt ?? last.startedAt,
528
+ endedAt: last.endedAt,
529
+ };
530
+
531
+ const flushSourceTurnId = last.turnId;
532
+ const payload: AutoSaveTriggerPayload = {
533
+ transcript: combinedTranscript,
534
+ sourceTurnId: flushSourceTurnId,
535
+ promptTemplate,
536
+ confidenceThreshold,
537
+ prefix,
538
+ // Use the NEW recognized peer-id, not the old anonymous one. By the time
539
+ // this flush fires, visitorAuth's verify-route has already migrated
540
+ // existing memory rows from anon-<threadId> to vis_<uuid> via
541
+ // migratePeerIdOnVerify. If we wrote new facts under priorPeerId, we'd
542
+ // recreate the orphaned-history regression that migration was designed
543
+ // to prevent. Pragmatic deviation from "Decision 5" of the memorist
544
+ // design (which said anonymous facts should remain anonymous): once a
545
+ // visitor proves identity, they own the conversation history they
546
+ // participated in.
547
+ peerId: currentPeerId,
548
+ };
549
+ const trigger: TurnTrigger = {
550
+ type: "internal",
551
+ turnId: `auto-save-flush-${priorPeerId}-${flushSourceTurnId}`,
552
+ threadId,
553
+ timestamp: Date.now(),
554
+ source: AUTO_SAVE_TRIGGER_SOURCE,
555
+ // Use the NEW recognized peer identity, not the old anon peer.
556
+ // Budget caps and turn gates key off trigger.peer, so the flush
557
+ // must target the recognized peer to get correct accounting.
558
+ peer: currentPeer,
559
+ payload,
560
+ };
561
+ try {
562
+ await ctx.inject(trigger);
563
+ } catch (err) {
564
+ console.warn(
565
+ `[layered-memory.autoSave] promotion-flush ctx.inject failed for prior peer=${priorPeerId}: ${(err as Error).message}`,
566
+ );
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Post-turn auto-save dispatcher. ADR-027 delivers `result` + `ctx`
572
+ * after every turn (including the extraction turns this augment
573
+ * itself injects — those are skipped by `isAutoSaveTurn` to prevent
574
+ * recursion).
575
+ *
576
+ * For decision === "extract", this hook calls `ctx.inject(...)` with
577
+ * an internal trigger; the kernel routes that trigger to this same
578
+ * augment's `handleInternalTurn`, which runs the extraction LLM call
579
+ * inside the admitted turn so cost flows through `turnGate.commit`.
580
+ * Errors during inject are caught and logged — best-effort per
581
+ * ADR-027 Decision 2.
582
+ *
583
+ * Promotion flush (post-PR-1 behavior): before applying the standard
584
+ * frequency dispatch, check whether the just-completed turn's peerId
585
+ * differs from the prior peerId for the same threadId AND the prior
586
+ * peerId was the anonymous form (`anon-<threadId>`). If so, inject a
587
+ * one-off extraction-flush trigger targeting the NEW recognized peer
588
+ * (see `maybeFlushOnPromotion` JSDoc for why this deviates from the
589
+ * original "Decision 5" of the memorist design).
590
+ */
591
+ async function scheduleAfterTurn(result: TurnResult, ctx: SchedulerContext): Promise<void> {
592
+ if (!autoSaveEnabled) return;
593
+ if (!promptTemplate) return;
594
+ // Recursion guard: skip extraction-initiated turns. Without this,
595
+ // every injected extraction turn would itself fire scheduleAfterTurn
596
+ // and synthesize another extraction trigger ad infinitum.
597
+ if (isAutoSaveTurn(result)) return;
598
+
599
+ const transcript = await ctx.getCompletedTranscript();
600
+ if (!transcript) return; // turn was compacted before the hook ran
601
+ if (!transcript.peer) return; // no peer, no scoped namespace to write under
602
+
603
+ const peerId = transcript.peer.id;
604
+ const threadId = transcript.threadId;
605
+
606
+ // Decision 5: detect anonymous→recognized promotion and flush
607
+ // anonymous-bound buffer BEFORE we apply the current peer's cadence.
608
+ // Pass the full peer object so trigger.peer targets the recognized
609
+ // identity (budget caps and turn gates key off trigger.peer).
610
+ await maybeFlushOnPromotion(threadId, transcript.peer, ctx);
611
+
612
+ // Update thread→peer history AFTER promotion detection so the
613
+ // detector compares against the prior turn's identity.
614
+ threadPeerHistory.set(threadId, peerId);
615
+
616
+ const turnIndex = turnIndexes.get(peerId) ?? 0;
617
+ turnIndexes.set(peerId, turnIndex + 1);
618
+
619
+ const decision = shouldExtract(
620
+ {
621
+ trustLevel: transcript.peer.trustLevel,
622
+ publicSubstate: transcript.peer.publicSubstate,
623
+ },
624
+ turnIndex,
625
+ frequencyConfig,
626
+ everyNTurns,
627
+ );
628
+
629
+ if (decision === "skip") return;
630
+ if (decision === "buffer") {
631
+ buffer.append(peerId, transcript);
632
+ return;
633
+ }
634
+
635
+ // decision === "extract"
636
+ if (!extractionEngine) {
637
+ // No engine configured — log once-per-turn and skip. The frequency
638
+ // dispatcher already advanced the turnIndex above so subsequent
639
+ // turns still honor the cadence even when extraction itself is a
640
+ // no-op.
641
+ console.warn(
642
+ `[layered-memory.autoSave] no extraction engine configured; skipping extraction for turn ${transcript.turnId}`,
643
+ );
644
+ return;
645
+ }
646
+
647
+ // Inject an internal trigger; the kernel routes back to this
648
+ // augment's handleInternalTurn (option a — extraction admits as its
649
+ // own turn, cost flows through turnGate.commit).
650
+ const sourceTurnId = transcript.turnId;
651
+ const payload: AutoSaveTriggerPayload = {
652
+ transcript,
653
+ sourceTurnId,
654
+ promptTemplate,
655
+ confidenceThreshold,
656
+ prefix,
657
+ peerId,
658
+ };
659
+ const trigger: TurnTrigger = {
660
+ type: "internal",
661
+ turnId: `auto-save-${sourceTurnId}`,
662
+ threadId: transcript.threadId,
663
+ timestamp: Date.now(),
664
+ source: AUTO_SAVE_TRIGGER_SOURCE,
665
+ peer: transcript.peer,
666
+ payload,
667
+ };
668
+ try {
669
+ await ctx.inject(trigger);
670
+ } catch (err) {
671
+ // ctx.inject failures are best-effort per ADR-027 — log and move
672
+ // on. The user-facing turn already succeeded; extraction loss is
673
+ // operationally low-stakes (next turn will retry on cadence).
674
+ console.warn(
675
+ `[layered-memory.autoSave] ctx.inject failed for sourceTurn=${sourceTurnId}: ${(err as Error).message}`,
676
+ );
677
+ }
678
+ }
679
+
680
+ /**
681
+ * ADR-027 Decision 5 internal-trigger handler. Claims triggers whose
682
+ * `source === "layered-memory.autoSave"` and runs the extraction LLM
683
+ * call inside the kernel's admitted turn. The returned TurnResult's
684
+ * `trace.inferenceSteps[]` carries the priced cost; the kernel
685
+ * turn-loop's `runCostCommit` aggregates and `turnGate.commit`
686
+ * observes — closing Codex Critical-2.
687
+ *
688
+ * Returns null for any trigger this augment does not own; the kernel
689
+ * then offers the trigger to the next augment's handleInternalTurn
690
+ * (or falls through to the standard inference loop if no augment
691
+ * claims).
692
+ */
693
+ async function handleInternalTurn(
694
+ trigger: TurnTrigger,
695
+ _ctx: InternalTurnContext,
696
+ ): Promise<TurnResult | null> {
697
+ if (trigger.source !== AUTO_SAVE_TRIGGER_SOURCE) return null;
698
+ if (!isAutoSaveTriggerPayload(trigger.payload)) {
699
+ // Defensive — a stray internal trigger named auto-save without
700
+ // the expected payload shape. Don't try to extract; surface as a
701
+ // failed turn so the misuse is visible in trace.
702
+ return buildExtractionTurnResult({
703
+ trigger,
704
+ status: "failed",
705
+ cost: { priced: false, reason: "auto-save trigger missing required payload fields" },
706
+ inferenceDurationMs: 0,
707
+ errorMessage: "auto-save trigger missing required payload fields",
708
+ });
709
+ }
710
+ if (!extractionEngine) {
711
+ // Configuration drifted between scheduleAfterTurn-time and here
712
+ // (e.g. operator hot-reloaded the engine to undefined). Best-effort
713
+ // — surface as a failed turn so the trace shows it.
714
+ return buildExtractionTurnResult({
715
+ trigger,
716
+ status: "failed",
717
+ cost: { priced: false, reason: "no extraction engine configured" },
718
+ inferenceDurationMs: 0,
719
+ errorMessage: "no extraction engine configured",
720
+ });
721
+ }
722
+ return runExtractionInsideTurn({
723
+ trigger,
724
+ transcript: trigger.payload.transcript as Transcript,
725
+ engine: extractionEngine,
726
+ promptTemplate: trigger.payload.promptTemplate,
727
+ store,
728
+ prefix: trigger.payload.prefix,
729
+ peerId: trigger.payload.peerId,
730
+ confidenceThreshold: trigger.payload.confidenceThreshold,
731
+ sourceTurnId: trigger.payload.sourceTurnId,
732
+ });
733
+ }
734
+
735
+ return {
736
+ name: `layered-memory-${opts.namespace}`,
737
+ capabilities: ["context", "tools"],
738
+ memory: {
739
+ owns: { kind: "namespace", prefix },
740
+ defaults: {
741
+ mutable: true,
742
+ origin: "peer-derived",
743
+ priority: "normal",
744
+ placement: "preamble",
745
+ eviction: "drop",
746
+ ttl: "session",
747
+ },
748
+ search,
749
+ write,
750
+ forget,
751
+ },
752
+ ...(autoSaveEnabled ? { scheduleAfterTurn, handleInternalTurn } : {}),
753
+ onShutdown: async () => {
754
+ await store.close();
755
+ },
756
+ };
757
+ }