@remnic/core 9.3.712 → 9.3.713

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 (51) hide show
  1. package/dist/access-boundary.d.ts +1 -1
  2. package/dist/access-boundary.js +2 -2
  3. package/dist/access-cli.js +5 -5
  4. package/dist/access-http.d.ts +1 -1
  5. package/dist/access-http.js +7 -7
  6. package/dist/access-mcp.d.ts +7 -1
  7. package/dist/access-mcp.js +6 -6
  8. package/dist/access-operations-batch.js +3 -3
  9. package/dist/access-operations.d.ts +1 -1
  10. package/dist/access-operations.js +5 -5
  11. package/dist/access-schema.d.ts +10 -0
  12. package/dist/access-schema.js +1 -1
  13. package/dist/{access-service-Dx2rSJjH.d.ts → access-service-DwVmvgBg.d.ts} +8 -1
  14. package/dist/access-service.d.ts +1 -1
  15. package/dist/access-service.js +1 -1
  16. package/dist/access-surface-catalog.d.ts +1 -1
  17. package/dist/{chunk-I3BT2IDW.js → chunk-DJNKN3XY.js} +31 -21
  18. package/dist/chunk-DJNKN3XY.js.map +1 -0
  19. package/dist/{chunk-NUWZFSKC.js → chunk-FVZ2ISIX.js} +9 -1
  20. package/dist/chunk-FVZ2ISIX.js.map +1 -0
  21. package/dist/{chunk-5KQCOIPW.js → chunk-JC3MIKR4.js} +4 -4
  22. package/dist/{chunk-ZVDRL6SK.js → chunk-RMT7PAVW.js} +2 -2
  23. package/dist/{chunk-YVWZW37D.js → chunk-UHN2TNSB.js} +25 -2
  24. package/dist/chunk-UHN2TNSB.js.map +1 -0
  25. package/dist/{chunk-Z3B2YW56.js → chunk-VUNI6Y3D.js} +4 -4
  26. package/dist/{chunk-A2R5NV6O.js → chunk-VW24K2Q4.js} +40 -25
  27. package/dist/chunk-VW24K2Q4.js.map +1 -0
  28. package/dist/{chunk-AMXOCZHE.js → chunk-ZDUF7K5V.js} +3 -3
  29. package/dist/{cli-DTWRoJEI.d.ts → cli-DfDMJqPP.d.ts} +1 -1
  30. package/dist/cli.d.ts +2 -2
  31. package/dist/cli.js +8 -8
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.js +8 -8
  34. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  35. package/dist/schemas.d.ts +22 -22
  36. package/dist/transfer/types.d.ts +12 -12
  37. package/package.json +2 -2
  38. package/src/access-http.ts +45 -19
  39. package/src/access-mcp.test.ts +1 -0
  40. package/src/access-mcp.ts +34 -17
  41. package/src/access-schema.ts +8 -0
  42. package/src/access-service-observe-idempotency.test.ts +308 -0
  43. package/src/access-service.ts +63 -3
  44. package/dist/chunk-A2R5NV6O.js.map +0 -1
  45. package/dist/chunk-I3BT2IDW.js.map +0 -1
  46. package/dist/chunk-NUWZFSKC.js.map +0 -1
  47. package/dist/chunk-YVWZW37D.js.map +0 -1
  48. /package/dist/{chunk-5KQCOIPW.js.map → chunk-JC3MIKR4.js.map} +0 -0
  49. /package/dist/{chunk-ZVDRL6SK.js.map → chunk-RMT7PAVW.js.map} +0 -0
  50. /package/dist/{chunk-Z3B2YW56.js.map → chunk-VUNI6Y3D.js.map} +0 -0
  51. /package/dist/{chunk-AMXOCZHE.js.map → chunk-ZDUF7K5V.js.map} +0 -0
@@ -477,6 +477,7 @@ test("MCP session override is injected only into tools that accept sessionKey",
477
477
  namespace: undefined,
478
478
  authenticatedPrincipal: undefined,
479
479
  skipExtraction: false,
480
+ idempotencyKey: undefined,
480
481
  cwd: undefined,
481
482
  projectTag: undefined,
482
483
  });
package/src/access-mcp.ts CHANGED
@@ -50,6 +50,12 @@ type McpRequestOptions = {
50
50
  sessionKeyOverride?: string;
51
51
  sessionId?: string;
52
52
  correlationId?: string;
53
+ /**
54
+ * Write-quota enforcer forwarded into observe's idempotency lock
55
+ * (issue #1649). Only the MCP-over-HTTP transport supplies this (it owns
56
+ * the write-rate-limit window); the standalone MCP server has no quota.
57
+ */
58
+ enforceWriteQuota?: () => void | Promise<void>;
53
59
  };
54
60
 
55
61
  type McpTool = {
@@ -1156,6 +1162,11 @@ export class EngramMcpServer {
1156
1162
  },
1157
1163
  description: "Conversation messages to observe",
1158
1164
  },
1165
+ idempotencyKey: {
1166
+ type: "string",
1167
+ description:
1168
+ "Optional idempotency key (issue #1649). Deduplicates a retried observe POST server-side so the batch is ingested once even when the HTTP response is lost. Reusing the key with a different payload is rejected.",
1169
+ },
1159
1170
  namespace: { type: "string" },
1160
1171
  skipExtraction: { type: "boolean" },
1161
1172
  cwd: { type: "string", description: "Working directory for auto git-context resolution." },
@@ -2374,7 +2385,7 @@ export class EngramMcpServer {
2374
2385
  argumentsObject = { ...argumentsObject, sessionKey: options.sessionKeyOverride };
2375
2386
  }
2376
2387
  const effectivePrincipal = options?.principalOverride ?? this.authenticatedPrincipal;
2377
- const result = await this.callTool(name, argumentsObject, effectivePrincipal, options?.sessionId);
2388
+ const result = await this.callTool(name, argumentsObject, effectivePrincipal, options?.sessionId, options?.enforceWriteQuota);
2378
2389
  return {
2379
2390
  jsonrpc: "2.0",
2380
2391
  id,
@@ -2562,7 +2573,7 @@ export class EngramMcpServer {
2562
2573
  }));
2563
2574
  }
2564
2575
 
2565
- private async callTool(name: string, args: Record<string, unknown>, effectivePrincipal?: string, mcpSessionId?: string): Promise<unknown> {
2576
+ private async callTool(name: string, args: Record<string, unknown>, effectivePrincipal?: string, mcpSessionId?: string, enforceWriteQuota?: () => void | Promise<void>): Promise<unknown> {
2566
2577
  // Migrated operations dispatch through the access boundary (issue #1525):
2567
2578
  // one registry entry owns schema validation, normalization (rules
2568
2579
  // 17/28/36/48/51), and error mapping for every surface. The switch
@@ -3104,21 +3115,27 @@ export class EngramMcpServer {
3104
3115
  );
3105
3116
  case "engram.observe": {
3106
3117
  const body = parseMcpRequest("observe", args);
3107
- return this.service.observe({
3108
- sessionKey: body.sessionKey,
3109
- messages: body.messages.map((message) => ({
3110
- role: message.role,
3111
- content: message.content,
3112
- parts: message.parts ?? undefined,
3113
- rawContent: message.rawContent ?? undefined,
3114
- sourceFormat: message.sourceFormat ?? undefined,
3115
- })),
3116
- namespace: body.namespace,
3117
- authenticatedPrincipal: effectivePrincipal,
3118
- skipExtraction: body.skipExtraction === true,
3119
- cwd: body.cwd,
3120
- projectTag: body.projectTag,
3121
- });
3118
+ return this.service.observe(
3119
+ {
3120
+ sessionKey: body.sessionKey,
3121
+ messages: body.messages.map((message) => ({
3122
+ role: message.role,
3123
+ content: message.content,
3124
+ parts: message.parts ?? undefined,
3125
+ rawContent: message.rawContent ?? undefined,
3126
+ sourceFormat: message.sourceFormat ?? undefined,
3127
+ })),
3128
+ skipExtraction: body.skipExtraction === true,
3129
+ idempotencyKey: body.idempotencyKey,
3130
+ namespace: body.namespace,
3131
+ authenticatedPrincipal: effectivePrincipal,
3132
+ cwd: body.cwd,
3133
+ projectTag: body.projectTag,
3134
+ },
3135
+ // Forward the transport's write-quota enforcer into observe's
3136
+ // idempotency lock so a replay is never 429'd (issue #1649).
3137
+ enforceWriteQuota ? { enforceWriteQuota } : undefined,
3138
+ );
3122
3139
  }
3123
3140
  case "engram.lcm_search":
3124
3141
  return this.service.lcmSearch({
@@ -216,6 +216,14 @@ export const observeRequestSchema = z.object({
216
216
  messages: z.array(messageSchema).min(1, "messages must be a non-empty array"),
217
217
  namespace: namespaceSchema,
218
218
  skipExtraction: z.boolean().optional(),
219
+ /**
220
+ * Optional idempotency key for server-side dedup of retried observe POSTs
221
+ * (issue #1649). A retry that reaches the daemon after the first attempt
222
+ * already processed is replayed from cache instead of re-ingested. When the
223
+ * key is reused with a DIFFERENT payload the request is rejected as a
224
+ * conflict (same contract as memory_store/suggestion_submit).
225
+ */
226
+ idempotencyKey: idempotencyKeySchema,
219
227
  /** Working directory for auto git-context resolution (issue #569). */
220
228
  cwd: z.string().trim().min(1, "cwd must be non-empty when provided").max(2048).optional(),
221
229
  /**
@@ -0,0 +1,308 @@
1
+ /**
2
+ * #1649: a retried observe POST (same `idempotencyKey`) must be deduplicated
3
+ * server-side — the batch is ingested exactly once even when the HTTP response
4
+ * is lost and the client replays the request. Without the key the daemon
5
+ * re-runs every side effect under `skipDedupeCheck: true` and the turn is
6
+ * queued for extraction twice.
7
+ *
8
+ * Verified here against the `EngramAccessService.observe` path with a stub
9
+ * orchestrator that records every namespace-bearing side effect (LCM enqueue +
10
+ * extraction replay), the same probe shape used by the #1495 observe-scope
11
+ * tests. The idempotency store is filesystem-backed, so `memoryDir` is a real
12
+ * temp directory (not the synthetic path the scope tests use, which never touch
13
+ * the store because they omit the key).
14
+ */
15
+ import assert from "node:assert/strict";
16
+ import { mkdtempSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import test from "node:test";
20
+
21
+ import { EngramAccessService } from "./access-service.js";
22
+ import { EngramAccessInputError } from "./access-service.js";
23
+ import { Orchestrator } from "./orchestrator.js";
24
+ import type { EngramAccessObserveRequest } from "./access-service.js";
25
+ import type { CodingContext, PluginConfig } from "./types.js";
26
+
27
+ interface ObserveProbe {
28
+ orch: Orchestrator;
29
+ contexts: Map<string, CodingContext>;
30
+ lcmCalls: Array<{ sessionKey: string }>;
31
+ extractionCalls: Array<{
32
+ sessionKeys: string[];
33
+ writeNamespaceOverride?: string;
34
+ principalOverride?: string;
35
+ }>;
36
+ }
37
+
38
+ function makeObserveProbe(memoryDir: string): ObserveProbe {
39
+ const contexts = new Map<string, CodingContext>();
40
+ const lcmCalls: ObserveProbe["lcmCalls"] = [];
41
+ const extractionCalls: ObserveProbe["extractionCalls"] = [];
42
+
43
+ const config = {
44
+ namespacesEnabled: true,
45
+ defaultNamespace: "default",
46
+ sharedNamespace: "shared",
47
+ namespacePolicies: [
48
+ { name: "pi-geek", readPrincipals: ["pi-geek"], writePrincipals: ["pi-geek"] },
49
+ ],
50
+ codingMode: { projectScope: true },
51
+ memoryDir,
52
+ // Disable objective-state snapshots so the probe doesn't need a writable
53
+ // storage backend — the idempotency contract under test is LCM + extraction.
54
+ objectiveStateMemoryEnabled: false,
55
+ objectiveStateSnapshotWritesEnabled: false,
56
+ principalFromSessionKeyMode: "prefix",
57
+ principalFromSessionKeyRules: [{ match: "pi-geek:", principal: "pi-geek" }],
58
+ recallCrossNamespaceBudgetEnabled: false,
59
+ recallCrossNamespaceBudgetWindowMs: 60_000,
60
+ recallCrossNamespaceBudgetSoftLimit: 10,
61
+ recallCrossNamespaceBudgetHardLimit: 30,
62
+ } as unknown as PluginConfig;
63
+
64
+ const orch = {
65
+ config,
66
+ getCodingContextForSession: (sk: string | undefined) =>
67
+ (sk ? contexts.get(sk) : null) ?? null,
68
+ setCodingContextForSession: (sk: string, ctx: CodingContext | null) => {
69
+ if (ctx === null) contexts.delete(sk);
70
+ else contexts.set(sk, ctx);
71
+ },
72
+ applyCodingNamespaceOverlay: (sk: string | undefined, base: string) =>
73
+ Orchestrator.prototype.applyCodingNamespaceOverlay.call(orch, sk, base),
74
+ getStorage: async (ns: string) => ({ dir: join(memoryDir, "storage", ns) }),
75
+ lcmEngine: {
76
+ enabled: true,
77
+ enqueueObserveMessages: (sessionKey: string) => {
78
+ lcmCalls.push({ sessionKey });
79
+ },
80
+ },
81
+ ingestReplayBatch: async (
82
+ turns: Array<{ sessionKey: string }>,
83
+ options: { writeNamespaceOverride?: string; principalOverride?: string } = {},
84
+ ) => {
85
+ extractionCalls.push({
86
+ sessionKeys: turns.map((t) => t.sessionKey),
87
+ writeNamespaceOverride: options.writeNamespaceOverride,
88
+ principalOverride: options.principalOverride,
89
+ });
90
+ },
91
+ } as unknown as Orchestrator;
92
+
93
+ return { orch, contexts, lcmCalls, extractionCalls };
94
+ }
95
+
96
+ function observeRequest(
97
+ overrides: Partial<EngramAccessObserveRequest>,
98
+ ): EngramAccessObserveRequest {
99
+ return {
100
+ sessionKey: "pi-geek:abc123",
101
+ messages: [
102
+ { role: "user", content: "what database are we using?" },
103
+ { role: "assistant", content: "we use postgres for the primary store" },
104
+ ],
105
+ ...overrides,
106
+ } as EngramAccessObserveRequest;
107
+ }
108
+
109
+ test("#1649 a retried observe with the same idempotencyKey is deduplicated to a single ingest", async () => {
110
+ const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-idem-"));
111
+ try {
112
+ const probe = makeObserveProbe(memoryDir);
113
+ const service = new EngramAccessService(probe.orch);
114
+ const key = "observe-batch-pi-geek-abc123-#1";
115
+
116
+ const first = await service.observe(observeRequest({ idempotencyKey: key }));
117
+ // First attempt is a real ingest.
118
+ assert.equal(first.idempotencyReplay, undefined, "first attempt is not a replay");
119
+ assert.equal(probe.extractionCalls.length, 1, "first attempt ingests once");
120
+ assert.equal(probe.lcmCalls.length, 1, "first attempt archives LCM once");
121
+
122
+ // Retry: same key, same payload — the daemon already processed the body, so
123
+ // this is the response-lost-after-process case the issue describes.
124
+ const second = await service.observe(observeRequest({ idempotencyKey: key }));
125
+ assert.equal(second.idempotencyReplay, true, "retry is served from the cache");
126
+ assert.equal(second.accepted, first.accepted, "retry reports the same accepted count");
127
+ assert.equal(
128
+ second.effectiveNamespace,
129
+ first.effectiveNamespace,
130
+ "retry reports the same effective namespace",
131
+ );
132
+
133
+ // The defining contract: NO second ingest, NO second LCM archive.
134
+ assert.equal(probe.extractionCalls.length, 1, "retry must not queue extraction a second time");
135
+ assert.equal(probe.lcmCalls.length, 1, "retry must not archive LCM a second time");
136
+ } finally {
137
+ rmSync(memoryDir, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test("#1649 observe without an idempotencyKey is never deduplicated (backward compatible)", async () => {
142
+ const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-nokey-"));
143
+ try {
144
+ const probe = makeObserveProbe(memoryDir);
145
+ const service = new EngramAccessService(probe.orch);
146
+
147
+ await service.observe(observeRequest({}));
148
+ await service.observe(observeRequest({}));
149
+
150
+ assert.equal(probe.extractionCalls.length, 2, "no key ⇒ both calls ingest");
151
+ assert.equal(probe.lcmCalls.length, 2, "no key ⇒ both calls archive LCM");
152
+ } finally {
153
+ rmSync(memoryDir, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test("#1649 reusing an idempotencyKey with a divergent payload is rejected as a conflict", async () => {
158
+ const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-conflict-"));
159
+ try {
160
+ const probe = makeObserveProbe(memoryDir);
161
+ const service = new EngramAccessService(probe.orch);
162
+ const key = "observe-batch-pi-geek-abc123-#2";
163
+
164
+ await service.observe(observeRequest({ idempotencyKey: key }));
165
+
166
+ // Same key, DIFFERENT messages — a stale key must never silently mask a
167
+ // unrelated batch. Same contract as memory_store/suggestion_submit.
168
+ await assert.rejects(
169
+ service.observe(
170
+ observeRequest({
171
+ idempotencyKey: key,
172
+ messages: [
173
+ { role: "user", content: "completely different question" },
174
+ { role: "assistant", content: "completely different answer" },
175
+ ],
176
+ }),
177
+ ),
178
+ (err: unknown) =>
179
+ err instanceof EngramAccessInputError &&
180
+ /idempotencyKey reuse conflict/.test(err.message),
181
+ "divergent payload under a reused key must throw a conflict",
182
+ );
183
+ } finally {
184
+ rmSync(memoryDir, { recursive: true, force: true });
185
+ }
186
+ });
187
+
188
+ test("#1649 review fix: enforceWriteQuota runs only on a cache miss, never on a replay", async () => {
189
+ const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-quota-hook-"));
190
+ try {
191
+ const probe = makeObserveProbe(memoryDir);
192
+ const service = new EngramAccessService(probe.orch);
193
+ const key = "observe-batch-pi-geek-abc123-#3";
194
+ let quotaChecks = 0;
195
+ const enforceWriteQuota = () => {
196
+ quotaChecks += 1;
197
+ };
198
+
199
+ await service.observe(observeRequest({ idempotencyKey: key }), { enforceWriteQuota });
200
+ assert.equal(quotaChecks, 1, "quota is enforced exactly once on the real ingest");
201
+
202
+ const replay = await service.observe(observeRequest({ idempotencyKey: key }), { enforceWriteQuota });
203
+ assert.equal(replay.idempotencyReplay, true, "second call is a replay");
204
+ assert.equal(quotaChecks, 1, "quota is NOT re-checked on a replay (response-lost retry must not 429)");
205
+ assert.equal(probe.extractionCalls.length, 1, "replay did not re-ingest");
206
+ } finally {
207
+ rmSync(memoryDir, { recursive: true, force: true });
208
+ }
209
+ });
210
+
211
+ test("#1649 review fix: the same idempotencyKey under a different principal is a conflict, not a silent replay", async () => {
212
+ const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-principal-conflict-"));
213
+ try {
214
+ const probe = makeObserveProbe(memoryDir);
215
+ const service = new EngramAccessService(probe.orch);
216
+ const key = "observe-batch-shared-key-#4";
217
+
218
+ // Principal alice ingests under the key.
219
+ await service.observe(
220
+ observeRequest({ idempotencyKey: key, authenticatedPrincipal: "alice" }),
221
+ );
222
+
223
+ // A different principal reusing the same key + same body must NOT replay
224
+ // alice's cached response — the fingerprint folds in authenticatedPrincipal,
225
+ // so this is a conflict (defends against cross-identity replay when the
226
+ // principal is supplied out-of-band via HTTP/MCP auth).
227
+ await assert.rejects(
228
+ service.observe(
229
+ observeRequest({ idempotencyKey: key, authenticatedPrincipal: "bob" }),
230
+ ),
231
+ (err: unknown) =>
232
+ err instanceof EngramAccessInputError &&
233
+ /idempotencyKey reuse conflict/.test(err.message),
234
+ "cross-principal key reuse must throw a conflict",
235
+ );
236
+ } finally {
237
+ rmSync(memoryDir, { recursive: true, force: true });
238
+ }
239
+ });
240
+
241
+ test("#1649 review fix: rebinding the session's ambient coding context makes the same key a conflict", async () => {
242
+ const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-ambient-scope-"));
243
+ try {
244
+ const probe = makeObserveProbe(memoryDir);
245
+ const service = new EngramAccessService(probe.orch);
246
+ const key = "observe-batch-ambient-scope-#5";
247
+ const req = observeRequest({ idempotencyKey: key });
248
+
249
+ // No explicit namespace/cwd/projectTag and no ambient context yet — the
250
+ // scope resolves to the default store. This caches the response.
251
+ await service.observe(req);
252
+ assert.equal(probe.extractionCalls.length, 1, "first observe ingests once");
253
+
254
+ // The session is now rebound to a coding project by an external caller
255
+ // (e.g. a memory_store with cwd, or a direct setCodingContextForSession).
256
+ // The resolved writeNamespace changed, so reusing the same key + same body
257
+ // must NOT silently replay — it is a conflict.
258
+ probe.contexts.set(req.sessionKey, {
259
+ projectId: "tag:remnic",
260
+ branch: null,
261
+ rootPath: "/projects/remnic",
262
+ defaultBranch: null,
263
+ });
264
+
265
+ await assert.rejects(
266
+ service.observe(req),
267
+ (err: unknown) =>
268
+ err instanceof EngramAccessInputError &&
269
+ /idempotencyKey reuse conflict/.test(err.message),
270
+ "key reuse after ambient scope rebind must throw a conflict, not silently replay",
271
+ );
272
+ } finally {
273
+ rmSync(memoryDir, { recursive: true, force: true });
274
+ }
275
+ });
276
+
277
+ test("#1649 review fix: projectTag observe replays correctly despite scope resolution side effects", async () => {
278
+ // The fingerprint folds in the RESOLVED writeNamespace (not the raw ambient
279
+ // context). resolveMemoryScopePlan seeds the session context from projectTag
280
+ // on the first call; the replay reads the SAME seeded context and resolves the
281
+ // SAME writeNamespace. This proves the fingerprint is stable across replays
282
+ // even though the scope resolution mutates session state on the first call.
283
+ const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-tag-replay-"));
284
+ try {
285
+ const probe = makeObserveProbe(memoryDir);
286
+ const service = new EngramAccessService(probe.orch);
287
+ const key = "observe-batch-tag-replay-#6";
288
+ const req = observeRequest({ idempotencyKey: key, projectTag: "remnic" });
289
+
290
+ const first = await service.observe(req);
291
+ assert.equal(first.idempotencyReplay, undefined, "first attempt is a real ingest");
292
+
293
+ // The first observe's resolveMemoryScopePlan seeded a coding context for
294
+ // this session. Verify it was actually set (the side effect we depend on).
295
+ assert.ok(
296
+ probe.contexts.get(req.sessionKey),
297
+ "first observe seeded a coding context via resolveMemoryScopePlan",
298
+ );
299
+
300
+ // Retry with the same key + same body + same projectTag — the pre-resolved
301
+ // writeNamespace matches because the seeded context produces the same scope.
302
+ const replay = await service.observe(req);
303
+ assert.equal(replay.idempotencyReplay, true, "projectTag observe replays despite scope-resolution side effects");
304
+ assert.equal(probe.extractionCalls.length, 1, "replay did not re-ingest");
305
+ } finally {
306
+ rmSync(memoryDir, { recursive: true, force: true });
307
+ }
308
+ });
@@ -1008,6 +1008,8 @@ export interface EngramAccessObserveRequest {
1008
1008
  namespace?: string;
1009
1009
  authenticatedPrincipal?: string;
1010
1010
  skipExtraction?: boolean;
1011
+ /** Optional idempotency key (issue #1649): a retried POST with the same key is deduplicated server-side; divergent reuse is a conflict. */
1012
+ idempotencyKey?: string;
1011
1013
  /**
1012
1014
  * Working directory of the calling agent session (issue #569 wiring).
1013
1015
  * When provided and no `codingContext` is attached for this session,
@@ -1067,6 +1069,8 @@ export interface EngramAccessObserveResponse {
1067
1069
  scopeDebug?: EngramAccessScopeDebug;
1068
1070
  lcmArchived: boolean;
1069
1071
  extractionQueued: boolean;
1072
+ /** True when replayed from the idempotency cache (issue #1649); lets the HTTP surface skip the write-quota slot, matching memory_store replay semantics. */
1073
+ idempotencyReplay?: boolean;
1070
1074
  }
1071
1075
 
1072
1076
  export interface EngramAccessLcmSearchRequest {
@@ -2523,8 +2527,8 @@ export class EngramAccessService {
2523
2527
  }
2524
2528
  }
2525
2529
 
2526
- private async handleIdempotentWrite<T extends EngramAccessWriteResponse>(options: {
2527
- operation: T["operation"];
2530
+ private async handleIdempotentWrite<T extends { idempotencyReplay?: boolean }>(options: {
2531
+ operation: string;
2528
2532
  idempotencyKey?: string;
2529
2533
  requestFingerprint: unknown;
2530
2534
  skip?: boolean;
@@ -5700,7 +5704,63 @@ export class EngramAccessService {
5700
5704
  return shapeMemorySummary(memory, baseDir, disclosure, rawExcerpts);
5701
5705
  }
5702
5706
 
5703
- async observe(request: EngramAccessObserveRequest): Promise<EngramAccessObserveResponse> {
5707
+ async observe(
5708
+ request: EngramAccessObserveRequest,
5709
+ hooks?: { enforceWriteQuota?: () => void | Promise<void> },
5710
+ ): Promise<EngramAccessObserveResponse> {
5711
+ // Issue #1649: dedup retried observe POSTs server-side. A retry with the
5712
+ // same `idempotencyKey` replays the cached response and skips every side
5713
+ // effect (LCM/extraction/objective-state); divergent payload OR principal
5714
+ // reuse is a conflict (fingerprint folds in authenticatedPrincipal, so a
5715
+ // cross-identity replay can never be silent). `enforceWriteQuota` runs as
5716
+ // `beforeExecute` inside the lock — only on a real miss — so a response-lost
5717
+ // retry never 429s even when the first attempt filled the window (#1434).
5718
+ //
5719
+ // The fingerprint folds in the EFFECTIVE coding context (#1649 codex P2):
5720
+ // runObserve's scope can be derived from the session's ATTACHED coding
5721
+ // context (which takes PRECEDENCE over per-call cwd/projectTag per
5722
+ // resolveMemoryScopePlan), so neither cwd/projectTag NOR the raw ambient
5723
+ // context alone fully captures the effective scope. The effective context
5724
+ // is computed the SAME way resolveMemoryScopePlan does — session-attached
5725
+ // first, per-call cwd/projectTag fallback — but READ-ONLY: it does NOT seed
5726
+ // the session, so a conflict/quota-rejection path leaves no orphaned binding.
5727
+ // The value is stable across replays because resolveMemoryScopePlan's
5728
+ // seeding writes the IDENTICAL context that resolveCodingContextFromOptions
5729
+ // derives — so getCodingContextForSession returns the same object on the
5730
+ // replay as resolveCodingContextFromOptions returned on the first call.
5731
+ // Only computed when a key is present; non-keyed observes skip the work.
5732
+ const idempotencyKey = request.idempotencyKey?.trim();
5733
+ let effectiveCodingContext: CodingContext | null | undefined;
5734
+ if (idempotencyKey && !(typeof request.namespace === "string" && request.namespace.trim().length > 0)) {
5735
+ // Explicit namespace pins the scope (resolveMemoryScopePlan returns early),
5736
+ // so the coding context is irrelevant — skip it to avoid false conflicts
5737
+ // when the session context changes under a namespace-pinned observe.
5738
+ // resolveCodingContextFromOptions self-gates on projectScope (no scattered
5739
+ // config read here).
5740
+ effectiveCodingContext =
5741
+ (typeof this.orchestrator.getCodingContextForSession === "function"
5742
+ ? this.orchestrator.getCodingContextForSession(request.sessionKey)
5743
+ : null) ?? (await this.resolveCodingContextFromOptions(request));
5744
+ }
5745
+ return this.handleIdempotentWrite<EngramAccessObserveResponse>({
5746
+ operation: "observe",
5747
+ idempotencyKey: request.idempotencyKey,
5748
+ requestFingerprint: {
5749
+ sessionKey: request.sessionKey,
5750
+ messages: request.messages,
5751
+ namespace: request.namespace,
5752
+ skipExtraction: request.skipExtraction,
5753
+ authenticatedPrincipal: request.authenticatedPrincipal,
5754
+ cwd: request.cwd,
5755
+ projectTag: request.projectTag,
5756
+ effectiveCodingContext: effectiveCodingContext ?? null,
5757
+ },
5758
+ beforeExecute: hooks?.enforceWriteQuota,
5759
+ execute: () => this.runObserve(request),
5760
+ });
5761
+ }
5762
+
5763
+ private async runObserve(request: EngramAccessObserveRequest): Promise<EngramAccessObserveResponse> {
5704
5764
  if (!request.sessionKey || typeof request.sessionKey !== "string" || request.sessionKey.trim().length === 0) {
5705
5765
  throw new EngramAccessInputError("sessionKey is required and must be a non-empty string");
5706
5766
  }