@remnic/core 9.63.10 → 9.63.11

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 (96) hide show
  1. package/dist/access-admin-ops-surface.d.ts +2 -1
  2. package/dist/access-admin-ops-surface.js +1 -1
  3. package/dist/access-authorization-probe.d.ts +2 -1
  4. package/dist/access-authorization-probe.js +2 -2
  5. package/dist/access-boundary.d.ts +2 -1
  6. package/dist/access-boundary.js +1 -1
  7. package/dist/access-cli.js +14 -14
  8. package/dist/access-extraction-force-flush.d.ts +2 -1
  9. package/dist/access-extraction-force-flush.js +1 -1
  10. package/dist/access-http-lcm-compaction.d.ts +1 -1
  11. package/dist/access-http-lifecycle-flush.d.ts +1 -1
  12. package/dist/access-http-offline-stream.d.ts +2 -1
  13. package/dist/access-http-query.js +1 -1
  14. package/dist/access-http.d.ts +3 -2
  15. package/dist/access-http.js +11 -11
  16. package/dist/access-identity-continuity-surface.js +1 -1
  17. package/dist/access-lcm-surface.d.ts +2 -1
  18. package/dist/access-lcm-surface.js +1 -1
  19. package/dist/access-mcp.d.ts +2 -1
  20. package/dist/access-mcp.js +7 -7
  21. package/dist/access-namespace-preflight.js +1 -1
  22. package/dist/access-observe-write-surface.d.ts +2 -1
  23. package/dist/access-observe-write-surface.js +1 -1
  24. package/dist/access-operations-batch.js +3 -2
  25. package/dist/access-operations.d.ts +4 -4
  26. package/dist/access-operations.js +6 -6
  27. package/dist/access-recall-concurrency.d.ts +2 -1
  28. package/dist/access-recall-concurrency.js +1 -1
  29. package/dist/access-recall-response.d.ts +2 -1
  30. package/dist/access-recall-response.js +1 -1
  31. package/dist/access-recall-surface.d.ts +2 -1
  32. package/dist/access-recall-surface.js +1 -1
  33. package/dist/access-schema.d.ts +92 -92
  34. package/dist/access-schema.js +1 -1
  35. package/dist/{access-service-C0AE0jLs.d.ts → access-service-U6jiERic.d.ts} +80 -155
  36. package/dist/access-service-helpers.d.ts +2 -1
  37. package/dist/access-service.d.ts +2 -1
  38. package/dist/access-service.js +1 -1
  39. package/dist/access-surface-catalog.d.ts +2 -1
  40. package/dist/access-surface-catalog.js +3 -3
  41. package/dist/{chunk-R7FQQQ23.js → chunk-36XS2ASP.js} +2 -2
  42. package/dist/{chunk-EPRBTGLM.js → chunk-57WMVU4D.js} +50 -41
  43. package/dist/chunk-57WMVU4D.js.map +1 -0
  44. package/dist/{chunk-34E5NBQI.js → chunk-BYFKJXIR.js} +2 -2
  45. package/dist/{chunk-LQNBQEMC.js → chunk-DIJCMHUE.js} +2 -2
  46. package/dist/{chunk-MNI7KB4Q.js → chunk-HP6B732E.js} +4 -4
  47. package/dist/{chunk-XFJUPJD7.js → chunk-IRXMJLTW.js} +11 -2
  48. package/dist/{chunk-XFJUPJD7.js.map → chunk-IRXMJLTW.js.map} +1 -1
  49. package/dist/{chunk-SAWGVTCE.js → chunk-IUSV4XJC.js} +2 -2
  50. package/dist/{chunk-K477BTF4.js → chunk-JASFZQ76.js} +4 -4
  51. package/dist/{chunk-SILX74CP.js → chunk-NWHKHBL4.js} +43 -63
  52. package/dist/chunk-NWHKHBL4.js.map +1 -0
  53. package/dist/{chunk-JJ45UTNT.js → chunk-R5AWIILH.js} +22 -27
  54. package/dist/chunk-R5AWIILH.js.map +1 -0
  55. package/dist/{chunk-IOE75NA4.js → chunk-V2YH7T6T.js} +2 -2
  56. package/dist/{chunk-H5VDBZLZ.js → chunk-VCEE4YNA.js} +2 -2
  57. package/dist/{chunk-U5XC6DIS.js → chunk-XREXYQ5U.js} +4 -4
  58. package/dist/{chunk-MIM7QT6H.js → chunk-YI25J2JD.js} +1 -1
  59. package/dist/chunk-YI25J2JD.js.map +1 -0
  60. package/dist/{cli-xoJTOdMQ.d.ts → cli-BsaYHAH9.d.ts} +1 -1
  61. package/dist/cli.d.ts +3 -2
  62. package/dist/cli.js +12 -12
  63. package/dist/external-wiki-access.d.ts +2 -1
  64. package/dist/external-wiki-access.js +2 -2
  65. package/dist/external-wiki-mcp-tools.d.ts +2 -1
  66. package/dist/index.d.ts +680 -680
  67. package/dist/index.js +21 -21
  68. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  69. package/dist/orchestrator.js +14 -14
  70. package/dist/{public-http-DR3sHfwk.d.ts → public-http-jMlmLny4.d.ts} +1 -1
  71. package/dist/schemas.d.ts +108 -108
  72. package/dist/shared-context/manager.d.ts +8 -8
  73. package/dist/support-passport/index.d.ts +5 -4
  74. package/dist/support-passport/index.js +4 -4
  75. package/dist/transfer/types.d.ts +66 -66
  76. package/package.json +2 -2
  77. package/src/access-http.ts +47 -60
  78. package/src/access-mcp.test.ts +6 -10
  79. package/src/access-mcp.ts +21 -22
  80. package/src/access-operations-batch.ts +66 -18
  81. package/src/access-schema.ts +12 -1
  82. package/src/access-service.ts +18 -91
  83. package/src/recall-disclosure.test.ts +1 -1
  84. package/dist/chunk-EPRBTGLM.js.map +0 -1
  85. package/dist/chunk-JJ45UTNT.js.map +0 -1
  86. package/dist/chunk-MIM7QT6H.js.map +0 -1
  87. package/dist/chunk-SILX74CP.js.map +0 -1
  88. /package/dist/{chunk-R7FQQQ23.js.map → chunk-36XS2ASP.js.map} +0 -0
  89. /package/dist/{chunk-34E5NBQI.js.map → chunk-BYFKJXIR.js.map} +0 -0
  90. /package/dist/{chunk-LQNBQEMC.js.map → chunk-DIJCMHUE.js.map} +0 -0
  91. /package/dist/{chunk-MNI7KB4Q.js.map → chunk-HP6B732E.js.map} +0 -0
  92. /package/dist/{chunk-SAWGVTCE.js.map → chunk-IUSV4XJC.js.map} +0 -0
  93. /package/dist/{chunk-K477BTF4.js.map → chunk-JASFZQ76.js.map} +0 -0
  94. /package/dist/{chunk-IOE75NA4.js.map → chunk-V2YH7T6T.js.map} +0 -0
  95. /package/dist/{chunk-H5VDBZLZ.js.map → chunk-VCEE4YNA.js.map} +0 -0
  96. /package/dist/{chunk-U5XC6DIS.js.map → chunk-XREXYQ5U.js.map} +0 -0
@@ -16,6 +16,7 @@ import {
16
16
  type WearablesMeetingsScope,
17
17
  type EngramAccessMemoryResponse,
18
18
  type EngramAccessWriteResponse,
19
+ type EngramAccessRecallResponse,
19
20
  } from "./access-service.js";
20
21
  import { maybeHandleLifecycleFlush, type LifecycleFlushHttpDeps } from "./access-http-lifecycle-flush.js";
21
22
  import {
@@ -32,7 +33,7 @@ import {
32
33
  OFFLINE_SYNC_FILE_CONTENT_MAX_CHUNK_BYTES,
33
34
  OFFLINE_SYNC_SNAPSHOT_BASE_MAX_BODY_BYTES,
34
35
  } from "./offline-sync.js";
35
- import type { RecallDisclosure, RecallPlanMode } from "./types.js";
36
+ import type { RecallDisclosure } from "./types.js";
36
37
  import { isRecallDisclosure } from "./types.js";
37
38
  import { isTrustZoneName, type TrustZoneName, type TrustZoneRecordKind, type TrustZoneSourceClass } from "./trust-zones.js";
38
39
  import { AdapterRegistry, type ResolvedIdentity } from "./adapters/index.js";
@@ -1020,13 +1021,8 @@ export class EngramAccessHttpServer extends SupportPassportAccessHttpBase {
1020
1021
  if (req.method === "POST" && pathname === "/engram/v1/recall") {
1021
1022
  this.enforceTokenOp("recall"); // boundary dispatch (issue #1525)
1022
1023
  const body = await this.readValidatedBody(req, "recall");
1023
- // Preserve the distinction between `codingContext: null` (explicit
1024
- // clear) and `codingContext` missing from the JSON payload
1025
- // (untouched). The previous `?? undefined` collapsed both into
1026
- // undefined, so callers lost the ability to clear the session's
1027
- // attached context through the recall endpoint.
1028
- const codingContext =
1029
- "codingContext" in body ? body.codingContext : undefined;
1024
+ // `codingContext` presence (null = explicit clear vs absent = untouched)
1025
+ // is preserved by spreading the parsed body into the boundary envelope.
1030
1026
  // Disclosure resolution (issue #677 PR 2/4): accept the value from
1031
1027
  // the validated body OR the `?disclosure=` query parameter, with
1032
1028
  // the body taking precedence so an explicit JSON payload is never
@@ -1109,31 +1105,31 @@ export class EngramAccessHttpServer extends SupportPassportAccessHttpBase {
1109
1105
  bodyIncludeLowConfidence === true ||
1110
1106
  (bodyIncludeLowConfidence === undefined &&
1111
1107
  queryIncludeLowConfidence === "true");
1112
- const response = await this.service.recall({
1113
- query: body.query ?? "",
1114
- sessionKey: body.sessionKey,
1115
- authenticatedPrincipal: this.resolveRequestPrincipal(req),
1116
- sourceConnector: this.resolveConnector(req),
1117
- idempotencyKey: body.idempotencyKey,
1118
- namespace: this.resolveNamespace(req, body.namespace),
1119
- topK: body.topK,
1120
- mode: body.mode as RecallPlanMode | "auto" | undefined,
1121
- includeDebug: body.includeDebug === true,
1122
- // Forward the validated disclosure depth to the service layer
1123
- // (issue #677). The zod schema accepts/rejects body values;
1124
- // `resolveRecallDisclosure()` validates the query-param fallback.
1125
- disclosure,
1126
- codingContext,
1127
- // Forward cwd/projectTag for auto git-context resolution (issue #569).
1128
- cwd: body.cwd,
1129
- projectTag: body.projectTag,
1130
- ...(asOf !== undefined ? { asOf } : {}),
1131
- ...(tags !== undefined ? { tags } : {}),
1132
- ...(tagMatch !== undefined ? { tagMatch } : {}),
1133
- ...(includeLowConfidence ? { includeLowConfidence: true } : {}),
1134
- abortSignal,
1135
- });
1136
- this.respondJson(res, 200, response);
1108
+ // Boundary dispatch (issue #2482): same registry path as memory_store —
1109
+ // the canonical recallRequestSchema re-validates the envelope the
1110
+ // transport assembled (resolved namespace + query-param overlays).
1111
+ const op = getOperation("recall");
1112
+ if (!op) {
1113
+ throw new EngramAccessInputError("access-boundary: operation not registered: recall");
1114
+ }
1115
+ const output = (await op.run(
1116
+ {
1117
+ ...body,
1118
+ namespace: this.resolveNamespace(req, body.namespace),
1119
+ ...(disclosure !== undefined ? { disclosure } : {}),
1120
+ ...(asOf !== undefined ? { asOf } : {}),
1121
+ ...(tags !== undefined ? { tags } : {}),
1122
+ ...(tagMatch !== undefined ? { tagMatch } : {}),
1123
+ ...(includeLowConfidence ? { includeLowConfidence: true } : {}),
1124
+ },
1125
+ {
1126
+ service: this.service,
1127
+ authenticatedPrincipal: this.resolveRequestPrincipal(req),
1128
+ sourceConnector: this.resolveConnector(req),
1129
+ ...(abortSignal ? { abortSignal } : {}),
1130
+ },
1131
+ )) as { result: EngramAccessRecallResponse };
1132
+ this.respondJson(res, 200, output.result);
1137
1133
  return;
1138
1134
  }
1139
1135
 
@@ -1745,43 +1741,34 @@ export class EngramAccessHttpServer extends SupportPassportAccessHttpBase {
1745
1741
  if (req.method === "POST" && pathname === "/engram/v1/observe") {
1746
1742
  this.enforceTokenOp("observe"); // boundary dispatch (issue #1525)
1747
1743
  const body = await this.readValidatedBody(req, "observe");
1748
- const response = await this.service.observe(
1744
+ // Boundary dispatch (issue #2482): same registry path as memory_store.
1745
+ // The write-quota hook is forwarded via ctx.hooks so it fires INSIDE
1746
+ // the service's idempotency lock (beforeExecute, only on a real miss).
1747
+ // A retried observe that hits the replay path must NOT be rejected with
1748
+ // 429 — the original attempt may have consumed the last quota slot; the
1749
+ // retry returns the cached response without requiring another (#1434
1750
+ // invariant, preserved by the boundary migration).
1751
+ const op = getOperation("observe");
1752
+ if (!op) {
1753
+ throw new EngramAccessInputError("access-boundary: operation not registered: observe");
1754
+ }
1755
+ const output = (await op.run(
1756
+ { ...body, namespace: this.resolveNamespace(req, body.namespace) },
1749
1757
  {
1750
- sessionKey: body.sessionKey,
1751
- messages: body.messages.map((message) => ({
1752
- role: message.role,
1753
- content: message.content,
1754
- sourceFormat: message.sourceFormat ?? undefined,
1755
- rawContent: message.rawContent ?? undefined,
1756
- parts: message.parts ?? undefined,
1757
- })),
1758
- namespace: this.resolveNamespace(req, body.namespace),
1758
+ service: this.service,
1759
1759
  authenticatedPrincipal: this.resolveRequestPrincipal(req),
1760
- skipExtraction: body.skipExtraction === true,
1761
- // Issue #1649: optional server-side dedup key for retried POSTs.
1762
- idempotencyKey: body.idempotencyKey,
1763
- // Forward cwd/projectTag for auto git-context resolution (issue #569).
1764
- cwd: body.cwd,
1765
- projectTag: body.projectTag,
1760
+ hooks: { enforceWriteQuota: () => this.ensureWriteRateLimitAvailable(req) },
1766
1761
  // Phase 1 provenance: server-resolved connector identity from the
1767
1762
  // bearer token (REST path, mirroring the MCP tools/call dispatch).
1768
1763
  sourceConnector: this.resolveConnector(req),
1769
1764
  },
1770
- // Enforce the write-quota INSIDE the service's idempotency lock
1771
- // (beforeExecute, only on a real miss). A retried observe that hits the
1772
- // replay path must NOT be rejected with 429 — that is exactly the
1773
- // response-lost-after-process case the dedup exists for. The original
1774
- // attempt may have consumed the last quota slot; the retry returns the
1775
- // cached response without requiring another. Matches memory_store's
1776
- // hook-in-the-lock pattern (#1434 invariant).
1777
- { enforceWriteQuota: () => this.ensureWriteRateLimitAvailable(req) },
1778
- );
1765
+ )) as { result: { dryRun?: boolean; idempotencyReplay?: boolean } };
1779
1766
  // A replayed (deduplicated) observe must not consume a second write-quota
1780
1767
  // slot — same invariant as memory_store/suggestion_submit (#1434).
1781
- if (this.shouldCountWriteRateLimit(response as { dryRun?: boolean; idempotencyReplay?: boolean })) {
1768
+ if (this.shouldCountWriteRateLimit(output.result)) {
1782
1769
  this.recordWriteRateLimitHit(req);
1783
1770
  }
1784
- this.respondJson(res, 202, response);
1771
+ this.respondJson(res, 202, output.result);
1785
1772
  return;
1786
1773
  }
1787
1774
 
@@ -472,16 +472,12 @@ test("MCP session override is injected only into tools that accept sessionKey",
472
472
  namespace: undefined,
473
473
  principal: "adapter-agent",
474
474
  });
475
- assert.deepEqual(observeArgs, {
476
- sessionKey: "adapter-session",
477
- messages: [{ role: "user", content: "hello", parts: undefined, rawContent: undefined, sourceFormat: undefined }],
478
- namespace: undefined,
479
- authenticatedPrincipal: undefined,
480
- skipExtraction: false,
481
- idempotencyKey: undefined,
482
- cwd: undefined,
483
- projectTag: undefined,
484
- });
475
+ assert.equal(observeArgs?.sessionKey, "adapter-session");
476
+ assert.equal(observeArgs?.skipExtraction, false);
477
+ const observeMessages = observeArgs?.messages as Array<{ role: string; content: string }>;
478
+ assert.equal(observeMessages?.length, 1);
479
+ assert.equal(observeMessages?.[0]?.role, "user");
480
+ assert.equal(observeMessages?.[0]?.content, "hello");
485
481
  assert.equal((capsuleResponse as Record<string, unknown> & { result?: { isError?: boolean } }).result?.isError, false);
486
482
  assert.equal((observeResponse as Record<string, unknown> & { result?: { isError?: boolean } }).result?.isError, false);
487
483
  });
package/src/access-mcp.ts CHANGED
@@ -326,6 +326,24 @@ const STRICT_MCP_SCHEMA_KEYS: Partial<Record<SchemaName, readonly string[]>> = {
326
326
  capsuleList: ["namespace", "sessionKey", "cwd", "projectTag"],
327
327
  };
328
328
 
329
+ // Tools whose envelope parses through the canonical zod wire schema (issue
330
+ // #2482) — the SAME validation the HTTP transport runs. Every other migrated
331
+ // tool passes raw args; its boundary op schema validates them.
332
+ const MCP_WIRE_SCHEMAS: Partial<Record<string, SchemaName>> = {
333
+ memory_store: "memoryStore",
334
+ suggestion_submit: "suggestionSubmit",
335
+ action_confidence: "actionConfidence",
336
+ day_summary: "daySummary",
337
+ capsule_export: "capsuleExport",
338
+ capsule_import: "capsuleImport",
339
+ capsule_list: "capsuleList",
340
+ observe: "observe",
341
+ recall: "recall",
342
+ lcm_compaction_flush: "lcmCompactionFlush",
343
+ extraction_force_flush: "extractionForceFlush",
344
+ lcm_compaction_record: "lcmCompactionRecord",
345
+ };
346
+
329
347
  // Shared JSON-schema fragments for the client-injected git/project context
330
348
  // fields (#1434). Declared once to avoid drift across tool definitions.
331
349
  // `_SCOPED` is for write tools that resolve a project namespace from these
@@ -2669,28 +2687,9 @@ export class EngramMcpServer {
2669
2687
  throw new EngramAccessInputError(`access-boundary: operation not registered: ${migrated}`);
2670
2688
  }
2671
2689
  let envelope: Record<string, unknown>;
2672
- if (migrated === "memory_store") {
2673
- envelope = parseMcpRequest("memoryStore", args);
2674
- } else if (migrated === "suggestion_submit") {
2675
- envelope = parseMcpRequest("suggestionSubmit", args);
2676
- } else if (migrated === "action_confidence") {
2677
- envelope = parseMcpRequest("actionConfidence", args);
2678
- } else if (migrated === "day_summary") {
2679
- envelope = parseMcpRequest("daySummary", args);
2680
- } else if (migrated === "capsule_export") {
2681
- envelope = parseMcpRequest("capsuleExport", args);
2682
- } else if (migrated === "capsule_import") {
2683
- envelope = parseMcpRequest("capsuleImport", args);
2684
- } else if (migrated === "capsule_list") {
2685
- envelope = parseMcpRequest("capsuleList", args);
2686
- } else if (migrated === "observe") {
2687
- envelope = parseMcpRequest("observe", args);
2688
- } else if (migrated === "lcm_compaction_flush") {
2689
- envelope = parseMcpRequest("lcmCompactionFlush", args);
2690
- } else if (migrated === "extraction_force_flush") {
2691
- envelope = parseMcpRequest("extractionForceFlush", args);
2692
- } else if (migrated === "lcm_compaction_record") {
2693
- envelope = parseMcpRequest("lcmCompactionRecord", args);
2690
+ const wireSchema = MCP_WIRE_SCHEMAS[migrated];
2691
+ if (wireSchema) {
2692
+ envelope = parseMcpRequest(wireSchema, args) as Record<string, unknown>;
2694
2693
  } else if (migrated.startsWith("codegraph_")) {
2695
2694
  envelope = { ...args, tool: migrated.slice("codegraph_".length) };
2696
2695
  } else if (migrated === "chat_message") {
@@ -15,6 +15,7 @@ import type { DreamsPhase, RecallDisclosure, RecallPlanMode } from "./types.js";
15
15
  import type { RemnicChatGptMemoryInspectorInput } from "./mcp-memory-inspector-app.js";
16
16
  import { defineOperation } from "./access-boundary.js";
17
17
  import { EngramAccessForbiddenError } from "./access-errors.js";
18
+ import { type ObserveRequest, observeRequestSchema, type RecallRequest, recallRequestSchema } from "./access-schema.js";
18
19
  import { EngramAccessInputError, type EngramAccessService } from "./access-service.js";
19
20
  import { enforceNamespaceAllowList, tokenCapabilityStore } from "./access-token-capabilities.js";
20
21
  import { projectTagProjectId } from "./coding/coding-namespace.js";
@@ -57,6 +58,24 @@ function strictSchema<T extends z.ZodRawShape>(shape: T): z.ZodType<Record<strin
57
58
  return z.preprocess(stripNulls, z.object(shape).passthrough()) as unknown as z.ZodType<Record<string, unknown>>;
58
59
  }
59
60
 
61
+ /**
62
+ * stripNulls, but keys listed in `preserve` keep their null values. Used by
63
+ * boundary schemas whose wire contract treats an explicit `null` as a
64
+ * meaningful value (e.g. recall `codingContext: null` clears the session
65
+ * context — it must not be collapsed into "absent").
66
+ */
67
+ function stripNullsExcept(data: unknown, preserve: readonly string[]): unknown {
68
+ if (data !== null && typeof data === "object" && !Array.isArray(data)) {
69
+ const cleaned: Record<string, unknown> = {};
70
+ for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
71
+ if (value !== null || preserve.includes(key)) cleaned[key] = value;
72
+ }
73
+ return cleaned;
74
+ }
75
+ return data;
76
+ }
77
+
78
+
60
79
  function optStr(v: unknown): string | undefined { return typeof v === "string" ? v : undefined; }
61
80
  function reqStr(v: unknown, field: string): string {
62
81
  if (typeof v !== "string" || v.length === 0) throw new EngramAccessInputError(field + " is required and must be a non-empty string");
@@ -77,24 +96,26 @@ const S = {
77
96
  };
78
97
 
79
98
  // === RECALL ===
80
- defineOperation({ name: "recall", description: "Semantic recall.", schema: strictSchema({ query: S.str, sessionKey: S.str, namespace: S.str, topK: S.num, mode: S.str, includeDebug: S.bool, disclosure: S.str, cwd: S.str, projectTag: S.str, asOf: S.str, tags: S.strArr, tagMatch: S.str }),
81
- handler: async (input, ctx) => {
82
- let disclosure: RecallDisclosure | undefined;
83
- if (input.disclosure !== undefined) { if (typeof input.disclosure !== "string") throw new EngramAccessInputError("disclosure must be a string"); disclosure = input.disclosure as RecallDisclosure; }
84
- if (input.cwd !== undefined && typeof input.cwd !== "string") throw new EngramAccessInputError("cwd must be a string");
85
- if (input.projectTag !== undefined && typeof input.projectTag !== "string") throw new EngramAccessInputError("projectTag must be a string");
86
- if (input.asOf !== undefined && typeof input.asOf !== "string") throw new EngramAccessInputError("asOf must be a string");
87
- let tags: string[] | undefined;
88
- if (input.tags !== undefined) { if (!Array.isArray(input.tags) || !input.tags.every((t) => typeof t === "string")) throw new EngramAccessInputError("tags must be an array of strings"); tags = input.tags; }
89
- let tagMatch: "any" | "all" | undefined;
90
- if (input.tagMatch !== undefined) { if (input.tagMatch !== "any" && input.tagMatch !== "all") throw new EngramAccessInputError("tagMatch must be one of: any, all"); tagMatch = input.tagMatch; }
91
- const result = await ctx.service.recall({ query: typeof input.query === "string" ? input.query : "", sessionKey: optStr(input.sessionKey), authenticatedPrincipal: ctx.authenticatedPrincipal, sourceConnector: ctx.sourceConnector, namespace: optStr(input.namespace), topK: optNum(input.topK), mode: optStr(input.mode) as RecallPlanMode | "auto" | undefined, includeDebug: input.includeDebug === true, disclosure, cwd: optStr(input.cwd), projectTag: optStr(input.projectTag), asOf: optStr(input.asOf), ...(tags ? { tags } : {}), ...(tagMatch ? { tagMatch } : {}), ...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}) });
92
- return { result };
93
- },
99
+ // Issue #2482: the boundary validates against the SAME canonical zod schema
100
+ // the HTTP transport uses (recallRequestSchema). No parallel field list, no
101
+ // silent defaults a missing `query` or a bad enum is a 400 at the boundary.
102
+ // `codingContext: null` survives the null-strip (explicit session-context
103
+ // clear); every other null is the MCP null-for-absent idiom.
104
+ defineOperation({
105
+ name: "recall",
106
+ description: "Semantic recall.",
107
+ schema: z.preprocess((data) => stripNullsExcept(data, ["codingContext"]), recallRequestSchema) as unknown as z.ZodType<RecallRequest>,
108
+ handler: async (input, ctx) => ({
109
+ result: await ctx.service.recall({
110
+ ...input,
111
+ authenticatedPrincipal: ctx.authenticatedPrincipal,
112
+ ...(ctx.sourceConnector ? { sourceConnector: ctx.sourceConnector } : {}),
113
+ ...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}),
114
+ }),
115
+ }),
94
116
  });
95
117
  defineOperation({ name: "recall_explain", description: "Explain recall plan.", schema: strictSchema({ sessionKey: S.str, namespace: S.str }), handler: async (input, ctx) => ({ result: await ctx.service.recallExplain({ sessionKey: optStr(input.sessionKey), namespace: optStr(input.namespace), authenticatedPrincipal: ctx.authenticatedPrincipal }) }) });
96
- defineOperation({ name: "set_coding_context", description: "Set coding context.", schema: z.preprocess((data) => { // Strip null from all fields EXCEPT codingContext (null = clear context).
97
- if (data !== null && typeof data === "object" && !Array.isArray(data)) { const o = data as Record<string, unknown>; const c: Record<string, unknown> = {}; for (const [k, v] of Object.entries(o)) { if (v !== null || k === "codingContext") c[k] = v; } return c; } return data; }, z.object({ sessionKey: S.str, codingContext: z.union([z.null(), z.object({ projectId: S.str, branch: z.union([z.string(), z.null()]).optional(), rootPath: S.str, defaultBranch: z.union([z.string(), z.null()]).optional() }).passthrough()]).optional(), projectTag: S.str }).passthrough()) as unknown as z.ZodType<Record<string, unknown>>,
118
+ defineOperation({ name: "set_coding_context", description: "Set coding context.", schema: z.preprocess((data) => stripNullsExcept(data, ["codingContext"]), z.object({ sessionKey: S.str, codingContext: z.union([z.null(), z.object({ projectId: S.str, branch: z.union([z.string(), z.null()]).optional(), rootPath: S.str, defaultBranch: z.union([z.string(), z.null()]).optional() }).passthrough()]).optional(), projectTag: S.str }).passthrough()) as unknown as z.ZodType<Record<string, unknown>>, // codingContext: null = clear context (preserved).
98
119
  handler: async (input, ctx) => {
99
120
  const sessionKey = defStr(input.sessionKey, "");
100
121
  const projectTag = optStr(input.projectTag);
@@ -190,8 +211,35 @@ defineOperation({ name: "memory_timeline", description: "Memory timeline.", sche
190
211
  defineOperation({ name: "suggestion_submit", description: "Submit suggestion.", schema: strictSchema({ schemaVersion: S.num, idempotencyKey: S.str, dryRun: S.bool, sessionKey: S.str, content: S.str, category: S.str, confidence: S.num, namespace: S.str, tags: S.strArr, entityRef: S.str, ttl: S.str, sourceReason: S.str, cwd: S.str, projectTag: S.str }), handler: async (input, ctx) => ({ result: await ctx.service.suggestionSubmit({ schemaVersion: optNum(input.schemaVersion), idempotencyKey: optStr(input.idempotencyKey), dryRun: input.dryRun === true, sessionKey: optStr(input.sessionKey), authenticatedPrincipal: ctx.authenticatedPrincipal, ...(ctx.sourceConnector ? { sourceConnector: ctx.sourceConnector } : {}), content: optStr(input.content) ?? "", category: optStr(input.category), confidence: optNum(input.confidence), namespace: optStr(input.namespace), tags: optStrArr(input.tags), entityRef: optStr(input.entityRef), ttl: optStr(input.ttl), sourceReason: optStr(input.sourceReason), cwd: optStr(input.cwd), projectTag: optStr(input.projectTag) }) }) });
191
212
  defineOperation({ name: "entity_get", description: "Get entity.", schema: strictSchema({ name: S.str, namespace: S.str }), handler: async (input, ctx) => ({ result: await ctx.service.entityGet(optStr(input.name) ?? "", optStr(input.namespace)) }) });
192
213
  defineOperation({ name: "review_queue_list", description: "List review queue.", schema: strictSchema({ runId: S.str, namespace: S.str }), handler: async (input, ctx) => ({ result: await ctx.service.reviewQueue(optStr(input.runId) ?? "", optStr(input.namespace), ctx.authenticatedPrincipal) }) });
193
- defineOperation({ name: "observe", description: "Observe messages.", schema: strictSchema({ sessionKey: S.str, messages: z.array(z.object({ role: z.string(), content: z.string(), parts: z.array(z.unknown()).optional(), rawContent: z.unknown().optional(), sourceFormat: z.string().optional() }).passthrough()), skipExtraction: S.bool, idempotencyKey: S.str, namespace: S.str, cwd: S.str, projectTag: S.str }),
194
- handler: async (input, ctx) => ({ result: await ctx.service.observe({ sessionKey: defStr(input.sessionKey, ""), messages: (input.messages as unknown[]).map((m: unknown) => { const r = m as Record<string, unknown>; return { role: String(r.role), content: String(r.content), parts: r.parts as unknown[], rawContent: r.rawContent as string | undefined, sourceFormat: r.sourceFormat as string | undefined }; }) as never, skipExtraction: input.skipExtraction === true, idempotencyKey: optStr(input.idempotencyKey), namespace: optStr(input.namespace), authenticatedPrincipal: ctx.authenticatedPrincipal, cwd: optStr(input.cwd), projectTag: optStr(input.projectTag), ...(ctx.sourceConnector ? { sourceConnector: ctx.sourceConnector } : {}) }, ctx.hooks?.enforceWriteQuota ? { enforceWriteQuota: ctx.hooks.enforceWriteQuota } : undefined) }),
214
+ // Issue #2482: observe validates through the canonical observeRequestSchema
215
+ // (same as HTTP/MCP wire validation). Nullable wire fields (parts /
216
+ // sourceFormat) map to undefined for the service's cleaned message form.
217
+ defineOperation({
218
+ name: "observe",
219
+ description: "Observe messages.",
220
+ schema: z.preprocess(stripNulls, observeRequestSchema) as unknown as z.ZodType<ObserveRequest>,
221
+ handler: async (input, ctx) => ({
222
+ result: await ctx.service.observe(
223
+ {
224
+ sessionKey: input.sessionKey,
225
+ messages: input.messages.map((message) => ({
226
+ role: message.role,
227
+ content: message.content,
228
+ ...(message.parts ? { parts: message.parts } : {}),
229
+ ...(message.rawContent !== undefined ? { rawContent: message.rawContent } : {}),
230
+ ...(message.sourceFormat ? { sourceFormat: message.sourceFormat } : {}),
231
+ })),
232
+ skipExtraction: input.skipExtraction === true,
233
+ idempotencyKey: input.idempotencyKey,
234
+ namespace: input.namespace,
235
+ authenticatedPrincipal: ctx.authenticatedPrincipal,
236
+ cwd: input.cwd,
237
+ projectTag: input.projectTag,
238
+ ...(ctx.sourceConnector ? { sourceConnector: ctx.sourceConnector } : {}),
239
+ },
240
+ ctx.hooks?.enforceWriteQuota ? { enforceWriteQuota: ctx.hooks.enforceWriteQuota } : undefined,
241
+ ),
242
+ }),
195
243
  });
196
244
  defineOperation({ name: "lcm_search", description: "Search LCM.", schema: strictSchema({ query: S.str, sessionKey: S.str, sessionPrefix: S.str, namespace: S.str, limit: S.num }), handler: async (input, ctx) => ({ result: await ctx.service.lcmSearch({ query: defStr(input.query, ""), sessionKey: optStr(input.sessionKey), sessionPrefix: optStr(input.sessionPrefix), namespace: optStr(input.namespace), limit: optNum(input.limit), authenticatedPrincipal: ctx.authenticatedPrincipal }) }) });
197
245
  defineOperation({ name: "lcm_compaction_flush", description: "Flush LCM compaction.", schema: strictSchema({ sessionKey: S.str, namespace: S.str, cwd: S.str, projectTag: S.str }), handler: async (input, ctx) => ({ result: await ctx.service.lcmCompactionFlush({ sessionKey: optStr(input.sessionKey) ?? "", namespace: optStr(input.namespace), cwd: optStr(input.cwd), projectTag: optStr(input.projectTag), authenticatedPrincipal: ctx.authenticatedPrincipal }) }) });
@@ -706,9 +706,20 @@ export function validateRequest<T = unknown>(
706
706
  },
707
707
  };
708
708
  }
709
- const result = schema.safeParse(body);
709
+ const result = schema.safeParse(stripNullsExceptCodingContext(body));
710
710
  if (result.success) {
711
711
  return { success: true, data: result.data as T };
712
712
  }
713
713
  return { success: false, error: formatZodError(result.error) };
714
714
  }
715
+
716
+ function stripNullsExceptCodingContext(data: unknown): unknown {
717
+ if (!data || typeof data !== "object" || Array.isArray(data)) return data;
718
+ const cleaned: Record<string, unknown> = {};
719
+ for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
720
+ if (value === null && key !== "codingContext") continue;
721
+ cleaned[key] = value;
722
+ }
723
+ return cleaned;
724
+ }
725
+
@@ -278,6 +278,7 @@ import {
278
278
  recordMemoryOutcome,
279
279
  } from "./memory-worth-outcomes.js";
280
280
  import type { LcmMessagePartInput, MessagePartSourceFormat } from "./message-parts/index.js";
281
+ import type { ObserveRequest, RecallRequest } from "./access-schema.js";
281
282
  import { recordObjectiveStateSnapshotsFromObservedMessages } from "./objective-state-writers.js";
282
283
  import { objectiveStateStoreOverrideForNamespace } from "./objective-state.js";
283
284
  import { offlineSyncStorageForSnapshot } from "./offline-sync-impression-drain.js";
@@ -373,17 +374,16 @@ function normalizeTrustZoneInputError(error: unknown): EngramAccessInputError |
373
374
 
374
375
  export const ENGRAM_ACCESS_WRITE_SCHEMA_VERSION = 1;
375
376
 
376
- export interface EngramAccessRecallRequest {
377
- query: string;
378
- sessionKey?: string;
379
- namespace?: string;
377
+ /**
378
+ * Recall request. Wire fields derive from the canonical zod schema
379
+ * (`recallRequestSchema`, access-schema.ts — issue #2482) so the HTTP body,
380
+ * the MCP args, and the service share ONE model. The overlay below carries
381
+ * server-resolved transport fields no client may set.
382
+ */
383
+ export interface EngramAccessRecallRequest extends RecallRequest {
380
384
  authenticatedPrincipal?: string;
381
385
  /** Trusted connector identity resolved at the transport boundary. */
382
386
  sourceConnector?: string;
383
- idempotencyKey?: string;
384
- topK?: number;
385
- mode?: RecallPlanMode | "auto";
386
- includeDebug?: boolean;
387
387
  abortSignal?: AbortSignal;
388
388
  /**
389
389
  * Internal, server-set field (issue #1906): wall-clock ms the caller
@@ -393,70 +393,6 @@ export interface EngramAccessRecallRequest {
393
393
  * (same pattern as `abortSignal`).
394
394
  */
395
395
  queueWaitMs?: number;
396
- /**
397
- * Recall disclosure depth. Omitting it preserves the `"chunk"` default.
398
- * Other accepted values are `"section"` and `"raw"`.
399
- */
400
- disclosure?: RecallDisclosure;
401
- /**
402
- * Coding-agent context (issue #569). When a connector resolves a git
403
- * context for the session's cwd, it passes it here and the access service
404
- * attaches it to the orchestrator before recall so project- / branch-
405
- * scoped namespace overlays apply.
406
- *
407
- * Keyed by `sessionKey`; ignored when `sessionKey` is absent.
408
- */
409
- codingContext?: {
410
- projectId: string;
411
- branch: string | null;
412
- rootPath: string;
413
- defaultBranch: string | null;
414
- } | null;
415
- /**
416
- * Working directory of the calling agent session. When provided and no
417
- * `codingContext` is already attached to the session, the service resolves
418
- * git context from this path and attaches it automatically. This enables
419
- * Claude Code hooks (and any connector that knows its cwd) to get
420
- * project-scoped memory without explicitly constructing a `codingContext`.
421
- */
422
- cwd?: string;
423
- /**
424
- * Arbitrary project tag for non-git-based project scoping. When provided,
425
- * creates a `CodingContext` with `projectId: "tag:<projectTag>"`.
426
- * Useful for OpenClaw sessions where the whole workspace is one git repo
427
- * but different conversations correspond to different client projects.
428
- * Takes precedence over `cwd`-based git resolution but NOT over an
429
- * explicit `codingContext`.
430
- */
431
- projectTag?: string;
432
- /**
433
- * Historical recall pin (issue #680). ISO 8601 timestamp. When set,
434
- * the orchestrator filters out memories whose `valid_at` is after this
435
- * instant OR whose `invalid_at` is at-or-before this instant, so the
436
- * caller sees the corpus as it existed at `asOf`. Invalid values are
437
- * rejected here with `EngramAccessInputError` (CLAUDE.md rule 51).
438
- */
439
- asOf?: string;
440
- /**
441
- * Free-form recall tag filter (issue #689). When non-empty, recall results
442
- * whose frontmatter `tags` do not match are removed from the response.
443
- * Comparison is case-sensitive exact match against tags stored on each
444
- * memory's frontmatter (see `storage.ts` and `docs/tags.md`).
445
- */
446
- tags?: string[];
447
- /**
448
- * Match mode for `tags` (issue #689). `"any"` (default when omitted)
449
- * admits results that carry at least one filter tag. `"all"` requires
450
- * every filter tag to be present. Ignored when `tags` is absent or empty.
451
- */
452
- tagMatch?: "any" | "all";
453
- /**
454
- * Issue #681 — when `true`, bypasses the configured
455
- * `graphTraversalConfidenceFloor` for this recall and includes graph edges
456
- * below the floor in traversal. Useful for diagnostic queries that need to
457
- * surface results pruned by confidence decay. Default `false`.
458
- */
459
- includeLowConfidence?: boolean;
460
396
  }
461
397
 
462
398
  /**
@@ -1033,27 +969,18 @@ export interface EngramAccessObserveMessage {
1033
969
  sourceFormat?: MessagePartSourceFormat;
1034
970
  }
1035
971
 
1036
- export interface EngramAccessObserveRequest extends SourceConnectorProvenance {
1037
- sessionKey: string;
972
+ /**
973
+ * Observe request. Wire fields derive from the canonical zod schema
974
+ * (`observeRequestSchema`, access-schema.ts — issue #2482); the service
975
+ * consumes the transport-cleaned message form (the wire schema tolerates
976
+ * nullable `parts`/`sourceFormat`, the service maps them to `undefined`).
977
+ */
978
+ export interface EngramAccessObserveRequest
979
+ extends Omit<ObserveRequest, "messages">,
980
+ SourceConnectorProvenance {
1038
981
  messages: EngramAccessObserveMessage[];
1039
- namespace?: string;
1040
982
  authenticatedPrincipal?: string;
1041
- skipExtraction?: boolean;
1042
- /** Optional idempotency key (issue #1649): a retried POST with the same key is deduplicated server-side; divergent reuse is a conflict. */
1043
- idempotencyKey?: string;
1044
- /**
1045
- * Working directory of the calling agent session (issue #569 wiring).
1046
- * When provided and no `codingContext` is attached for this session,
1047
- * resolves git context from the path and attaches it so writes route to
1048
- * the correct project namespace.
1049
- */
1050
- cwd?: string;
1051
- /**
1052
- * Arbitrary project tag for non-git-based project scoping (issue #569).
1053
- * Creates a `CodingContext` with `projectId: "tag:<projectTag>"`.
1054
- */
1055
- projectTag?: string;
1056
- readonly suppressQuarantine?: boolean; // #1888: mirrors EngramAccessWriteEnvelope.suppressQuarantine (replay re-submit skips dead-lettering).
983
+ readonly suppressQuarantine?: boolean; // #1888: replay re-submit skips dead-lettering.
1057
984
  }
1058
985
 
1059
986
  /**
@@ -191,7 +191,7 @@ test("MCP engram.recall rejects non-string disclosure with a structured error",
191
191
  const result = (response as { result?: { isError?: boolean; content?: Array<{ text?: string }> } } | undefined)?.result;
192
192
  assert.strictEqual(result?.isError, true);
193
193
  const text = result?.content?.[0]?.text ?? "";
194
- assert.match(text, /disclosure must be a string/i);
194
+ assert.match(text, /disclosure/i);
195
195
  });
196
196
 
197
197
  test("MCP engram.recall treats explicit `null` disclosure as absent (service applies default)", async () => {