@smartmemory/compose 0.3.7 → 0.4.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 (217) hide show
  1. package/.claude/skills/compose/SKILL.md +12 -3
  2. package/.compose-deps.json +51 -25
  3. package/README.md +79 -7
  4. package/bin/compose.js +495 -360
  5. package/bin/judgment-migrate.js +387 -0
  6. package/contracts/comp-obs-contract.schema.json +9 -3
  7. package/contracts/fluid-record.schema.json +209 -0
  8. package/contracts/lifecycle-backfill.schema.json +322 -0
  9. package/dist/assets/App-Z4MU-H_F.js +916 -0
  10. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  11. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  14. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  15. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  16. package/dist/assets/channel-SnZzzh7k.js +1 -0
  17. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  18. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  19. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  20. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  21. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  22. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  23. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  24. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  25. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  26. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  27. package/dist/assets/clone-DgklGjHm.js +1 -0
  28. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  29. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  30. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  31. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  32. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  33. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  34. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  35. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  36. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  37. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  38. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  39. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  40. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  41. package/dist/assets/index-yyrA5OZd.css +1 -0
  42. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  46. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  47. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  48. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  49. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  50. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  51. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  52. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  53. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  56. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  57. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  58. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  59. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  60. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  61. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  62. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  63. package/dist/index.html +3 -3
  64. package/lib/agent-string.js +7 -5
  65. package/lib/append-integrity.js +81 -0
  66. package/lib/backfill-evidence.js +109 -0
  67. package/lib/bug-escalation.js +9 -0
  68. package/lib/build-stream-schema.js +3 -1
  69. package/lib/build-stream-writer.js +25 -0
  70. package/lib/build.js +874 -170
  71. package/lib/canon-guard.js +28 -6
  72. package/lib/canon-override.js +196 -0
  73. package/lib/canon-registry.js +104 -0
  74. package/lib/cli-commands.js +144 -0
  75. package/lib/codex-preflight.js +26 -13
  76. package/lib/colleague/context.js +215 -0
  77. package/lib/colleague/writeback.js +95 -0
  78. package/lib/completion-gate.js +1421 -0
  79. package/lib/completion-writer.js +47 -47
  80. package/lib/consumer-fanout.js +105 -11
  81. package/lib/coverage-gate.js +200 -0
  82. package/lib/deps.js +164 -7
  83. package/lib/dir-lock.js +170 -0
  84. package/lib/dispatch-ledger.js +3 -3
  85. package/lib/feature-json.js +1 -1
  86. package/lib/feature-reconciler.js +8 -0
  87. package/lib/feature-validator.js +64 -1
  88. package/lib/feature-writer.js +57 -2
  89. package/lib/fluid/factory.js +167 -0
  90. package/lib/fluid/ideabox-dates.js +73 -0
  91. package/lib/fluid/ideabox-migrate.js +154 -0
  92. package/lib/fluid/ideabox-ops.js +585 -0
  93. package/lib/fluid/ideabox-view.js +146 -0
  94. package/lib/fluid/import-ideabox.js +186 -0
  95. package/lib/fluid/local-provider.js +606 -0
  96. package/lib/fluid/provider.js +684 -0
  97. package/lib/fluid/record-shape.js +214 -0
  98. package/lib/fluid/record-store.js +328 -0
  99. package/lib/fluid/render-ideabox.js +261 -0
  100. package/lib/fluid/schema.js +40 -0
  101. package/lib/fluid/smartmemory-provider.js +1695 -0
  102. package/lib/gsd.js +63 -23
  103. package/lib/guard-cli.js +175 -0
  104. package/lib/guard-custody.js +141 -0
  105. package/lib/guard-descriptors.js +530 -0
  106. package/lib/guard-enrol.js +254 -0
  107. package/lib/health-score.js +1 -1
  108. package/lib/ideabox-cli.js +315 -0
  109. package/lib/ideabox.js +121 -21
  110. package/lib/judgment/store/index.js +9 -1
  111. package/lib/judgment/store/records.js +1 -1
  112. package/lib/judgment/trace.js +380 -0
  113. package/lib/judgment-decision-write.js +277 -0
  114. package/lib/judgment-decisions.js +466 -0
  115. package/lib/judgment-gen.js +5 -1
  116. package/lib/judgment-writer.js +56 -2
  117. package/lib/lifecycle-modes.js +4 -4
  118. package/lib/lineage.js +400 -0
  119. package/lib/local-claude-connector.js +52 -1
  120. package/lib/maya-client.js +302 -0
  121. package/lib/maya-config.js +53 -0
  122. package/lib/maya-identity.js +283 -0
  123. package/lib/migrate-anon.js +5 -0
  124. package/lib/migrate-roadmap.js +15 -0
  125. package/lib/new.js +13 -1
  126. package/lib/pipeline-compat.js +104 -0
  127. package/lib/policy-catalog.js +295 -0
  128. package/lib/policy-check.js +0 -0
  129. package/lib/process-termination.js +98 -0
  130. package/lib/resolve-workspace.js +5 -1
  131. package/lib/result-normalizer.js +396 -199
  132. package/lib/roadmap-errors.js +65 -0
  133. package/lib/roadmap-preservers.js +24 -4
  134. package/lib/roadmap-residue.js +299 -0
  135. package/lib/smartmemory-client.js +614 -78
  136. package/lib/smartmemory-config.js +54 -0
  137. package/lib/smartmemory-ingest.js +19 -2
  138. package/lib/step-prompt.js +7 -6
  139. package/lib/stratum-engine.js +53 -4
  140. package/lib/stratum-mcp-client.js +271 -36
  141. package/lib/test-bootstrap.js +31 -0
  142. package/lib/tool-inventory.js +122 -0
  143. package/lib/version-check.js +91 -19
  144. package/lib/vision-writer.js +88 -1
  145. package/package.json +7 -6
  146. package/pipelines/bug-fix.stratum.yaml +205 -211
  147. package/pipelines/build-quick.profiles.json +12 -0
  148. package/pipelines/build-quick.stratum.yaml +263 -350
  149. package/pipelines/content.stratum.yaml +81 -77
  150. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  151. package/pipelines/plan.stratum.yaml +76 -86
  152. package/pipelines/refactor.stratum.yaml +125 -125
  153. package/pipelines/research.stratum.yaml +56 -58
  154. package/pipelines/review-fix.profiles.json +6 -0
  155. package/pipelines/review-fix.stratum.yaml +110 -83
  156. package/presets/team-feature.profiles.json +6 -0
  157. package/presets/team-feature.stratum.yaml +93 -66
  158. package/presets/team-research.profiles.json +6 -0
  159. package/presets/team-research.stratum.yaml +89 -80
  160. package/presets/team-review.profiles.json +8 -0
  161. package/presets/team-review.stratum.yaml +98 -80
  162. package/scripts/cost-census.mjs +70 -0
  163. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  164. package/server/agent-health.js +22 -0
  165. package/server/agent-hooks.js +14 -1
  166. package/server/agent-server.js +5 -248
  167. package/server/agent-spawn.js +3 -4
  168. package/server/agent-workspace.js +294 -0
  169. package/server/build-routes.js +6 -5
  170. package/server/build-stream-bridge.js +53 -0
  171. package/server/cc-session-watcher.js +4 -1
  172. package/server/coalescing-buffer.js +7 -1
  173. package/server/completion-projection.js +228 -0
  174. package/server/compose-mcp-tools.js +109 -23
  175. package/server/compose-mcp.js +88 -882
  176. package/server/decision-event-emit.js +41 -2
  177. package/server/decision-event-id.js +17 -0
  178. package/server/decision-events-snapshot.js +3 -0
  179. package/server/design-routes.js +14 -8
  180. package/server/feature-scan.js +76 -2
  181. package/server/file-watcher.js +170 -21
  182. package/server/ideabox-routes.js +166 -224
  183. package/server/index.js +70 -100
  184. package/server/lifecycle-guard.js +240 -10
  185. package/server/lifecycle-phase-history.js +276 -0
  186. package/server/maya-routes.js +507 -0
  187. package/server/mcp-tool-defs.js +940 -0
  188. package/server/mcp-tool-policy.js +34 -2
  189. package/server/model-tiers.js +22 -5
  190. package/server/pipeline-routes.js +21 -11
  191. package/server/project-root.js +58 -19
  192. package/server/remote-utils.js +3 -1
  193. package/server/schema-validator.js +7 -1
  194. package/server/session-manager.js +5 -6
  195. package/server/session-routes.js +3 -1
  196. package/server/stratum-client.js +57 -10
  197. package/server/stratum-sync.js +6 -3
  198. package/server/summarizer.js +3 -4
  199. package/server/supervisor.js +0 -1
  200. package/server/vision-routes.js +208 -98
  201. package/server/vision-server.js +86 -23
  202. package/server/vision-store.js +60 -6
  203. package/server/vision-utils.js +3 -4
  204. package/server/workspace-activity.js +18 -0
  205. package/server/workspace-middleware.js +2 -2
  206. package/server/workspace-runtime.js +243 -0
  207. package/server/worktree-gc.js +1 -0
  208. package/dist/assets/App-PkZzHeMj.js +0 -894
  209. package/dist/assets/channel-qVK_qn4E.js +0 -1
  210. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  211. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  212. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  213. package/dist/assets/index-LIwREYgH.css +0 -1
  214. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  215. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  216. package/lib/staleness.js +0 -87
  217. package/server/ideabox-cache.js +0 -77
@@ -1,17 +1,61 @@
1
1
  /**
2
- * smartmemory-client.js — COMP-SMARTMEMORY-INGEST S02
2
+ * smartmemory-client.js — COMP-SMARTMEMORY-INGEST S02, extended by COMP-FOH S01,
3
+ * rebuilt onto `@smartmemory/sdk-js` by COMP-FLUID-SEAM-GUARANTEES.
3
4
  *
4
- * Raw HTTP client for the SmartMemory ingest/search wire contract. Global
5
- * `fetch` + `AbortController` timeout. No SDK, no new dependency.
5
+ * Compose's POLICY over SmartMemory's wire contract. The transport underneath —
6
+ * base-URL joining, `Authorization`, `X-Workspace-Id`, request serialization,
7
+ * FastAPI error-detail extraction, the 401 path — belongs to the published SDK
8
+ * and is no longer duplicated here. What remains is the part that is genuinely
9
+ * Compose's opinion and would have to be re-stated at every call site otherwise:
10
+ *
11
+ * - `use_pipeline: false` by default, inverting the route's own default
12
+ * - `channel_weights: {}` on every scoped search
13
+ * - a miss is `null` on read, and an exception on write
14
+ * - a 2xx we cannot trust is a failure, not a success
15
+ * - the API key is resolved at CALL time, so an unset key never reaches the wire
16
+ *
17
+ * WHY NOT `client.memories.*`
18
+ * --------------------------
19
+ * The SDK's `MemoryAPI` models the same routes but not the same contract, and
20
+ * three of the differences are load-bearing here rather than cosmetic:
21
+ *
22
+ * 1. `get`/`update`/`delete` interpolate the item id RAW. An id containing `/`
23
+ * forges a path. This module URL-encodes it (asserted in the tests).
24
+ * 2. `search()` omits `channel_weights`, which hands recall behaviour to the
25
+ * API key's stored agent profile — see `searchItems` below.
26
+ * 3. `delete()` has no `cleanup_orphans`, and `create()` sends `profile_name`
27
+ * this caller never sets.
28
+ *
29
+ * So the memory routes are issued through the SDK's `BaseAPI` transport with
30
+ * Compose's own bodies. `sequences` and `locks` ARE used wholesale: those two
31
+ * match exactly, and re-implementing an allocator or a lease client is precisely
32
+ * the duplication this rebuild exists to delete.
33
+ *
34
+ * Two families, deliberately kept apart:
35
+ * - `health`/`ingest`/`search` — the pipeline surface, shipped and in use.
36
+ * These send NO `X-Workspace-Id`, so they address the key's default scope.
37
+ * - `createItem`/`getItem`/`listItems`/`updateItem`/`deleteItem`/`searchItems`
38
+ * plus the sequence and lease primitives — the scoped surface, which always
39
+ * sends `X-Workspace-Id` when one is configured.
40
+ *
41
+ * That split is why two SDK clients are built rather than one: the header is
42
+ * attached by the SDK's auth core for every request it issues, so "scoped" and
43
+ * "unscoped" cannot be the same client. Sharing one would silently move the
44
+ * pipeline surface into the configured workspace.
45
+ *
46
+ * Everything here speaks HTTP and nothing else. Domain mapping — what a record
47
+ * is, how it is identified, how it serializes — belongs to the caller.
6
48
  */
7
49
 
50
+ import { APIError, SmartMemoryClient } from '@smartmemory/sdk-js/core';
51
+
8
52
  /**
9
- * Thrown on non-2xx from ingest/search, OR on a 2xx whose body doesn't match
10
- * the expected shape (non-JSON, or missing the field the caller depends on —
11
- * `status` for ingest, `results` for search). The latter case sets
12
- * `kind: 'malformed-response'` so callers can tell "the service said no" from
13
- * "the service said something we can't trust" if they want to, while both
14
- * still surface as one failure type upstream (sync: `failed`; emitter:
53
+ * Thrown on non-2xx from any call, OR on a 2xx whose body doesn't match the
54
+ * expected shape (non-JSON, or missing the field the caller depends on —
55
+ * `status` for ingest, `results` for search, `id` for createItem). The latter
56
+ * case sets `kind: 'malformed-response'` so callers can tell "the service said
57
+ * no" from "the service said something we can't trust" if they want to, while
58
+ * both still surface as one failure type upstream (sync: `failed`; emitter:
15
59
  * counts toward the circuit breaker).
16
60
  */
17
61
  export class SmartmemoryHttpError extends Error {
@@ -23,117 +67,609 @@ export class SmartmemoryHttpError extends Error {
23
67
  }
24
68
  }
25
69
 
70
+ /** Marker carried on an `APIError.detail` so a malformed 2xx detected down in
71
+ * the fetch layer survives `BaseAPI`'s error handling and can be re-typed with
72
+ * its `kind` intact. `BaseAPI` rethrows an `APIError` unchanged and only wraps
73
+ * OTHER throwables, so raising one is the supported way to pass a verdict up. */
74
+ const MALFORMED = 'malformed-response';
75
+
26
76
  /**
27
77
  * Build a client bound to a resolved config. The API key is read from
28
78
  * process.env[cfg.apiKeyEnv] at call time (missing ⇒ treated as unreachable).
29
- * @param {{ baseUrl: string, apiKeyEnv?: string, timeoutMs?: number }} cfg
30
- * @returns {{ health(): Promise<{ok:boolean,status?:number}>, ingest(content:string,ctx:object): Promise<{status:string,unchanged:boolean,raw:object}>, search(query:string,opts?:object): Promise<object> }}
79
+ * @param {{ baseUrl: string, apiKeyEnv?: string, timeoutMs?: number, workspaceId?: string }} cfg
80
+ * `workspaceId`, when set, is sent as `X-Workspace-Id` on the scoped methods.
81
+ * @returns {object} the client surface documented per-method below
31
82
  */
32
83
  export function createSmartmemoryClient(cfg) {
33
84
  const baseUrl = cfg.baseUrl;
34
85
  const timeoutMs = cfg.timeoutMs ?? 3000;
35
86
 
36
- async function withTimeout(fn) {
87
+ /**
88
+ * The one piece of transport Compose still owns, because the SDK has no
89
+ * opinion on either half:
90
+ *
91
+ * - a deadline, so a hung service cannot pin a CLI open. The timer spans the
92
+ * body read as well as the response headers; the old hand-rolled client
93
+ * cleared it early and could therefore hang forever on a stalled body.
94
+ * - the "a 2xx must be JSON" rule. An upstream proxy answering 200 with an
95
+ * HTML error page is the case this exists for: without the check it
96
+ * reaches `BaseAPI` as a parse failure indistinguishable from a network
97
+ * error, and a caller cannot tell a dead proxy from a dead socket.
98
+ *
99
+ * Returns a Response-SHAPED object rather than the real one because the body
100
+ * has already been consumed here. `BaseAPI` reads `.ok`, `.status`,
101
+ * `.headers.get()`, `.json()` and `.text()`; all five are provided.
102
+ */
103
+ async function fetchWithContract(url, init = {}) {
104
+ // A per-call `timeoutMs` (passed as the SDK post's 3rd options arg, which
105
+ // `getRequestOptions` preserves) overrides the client default for ONE call.
106
+ // Stripped before `fetch` so it is never sent as a bogus request-init field.
107
+ // Every existing caller omits it, so the 3s default is unchanged for them.
108
+ const { timeoutMs: callTimeoutMs, ...fetchInit } = init;
37
109
  const controller = new AbortController();
38
- const timer = setTimeout(() => controller.abort(), timeoutMs);
110
+ const timer = setTimeout(() => controller.abort(), callTimeoutMs ?? timeoutMs);
111
+ let res;
112
+ let text;
39
113
  try {
40
- return await fn(controller.signal);
114
+ res = await fetch(url, { ...fetchInit, signal: controller.signal });
115
+ text = res.status === 204 ? '' : await res.text();
41
116
  } finally {
42
117
  clearTimeout(timer);
43
118
  }
119
+
120
+ let parsed = null;
121
+ let parseFailed = false;
122
+ try {
123
+ parsed = JSON.parse(text);
124
+ } catch {
125
+ parseFailed = true;
126
+ }
127
+
128
+ // 204 is exempt: it has no body by definition and BaseAPI returns null for
129
+ // it without ever asking for one.
130
+ if (res.ok && res.status !== 204 && parseFailed) {
131
+ throw new APIError(
132
+ `a 2xx (HTTP ${res.status}) carried a non-JSON body`,
133
+ res.status,
134
+ { composeKind: MALFORMED },
135
+ );
136
+ }
137
+
138
+ return {
139
+ ok: res.ok,
140
+ status: res.status,
141
+ headers: res.headers,
142
+ json: async () => {
143
+ if (parseFailed) throw new SyntaxError('non-JSON body');
144
+ return parsed;
145
+ },
146
+ text: async () => text,
147
+ };
44
148
  }
45
149
 
46
- function authHeader() {
150
+ // The SDK client is rebuilt whenever the resolved key changes, which is what
151
+ // keeps "the key is read at call time" true without paying for a fresh client
152
+ // on every request. `storage: 'memory'` is required off-browser — the default
153
+ // reaches for localStorage.
154
+ let cache = null;
155
+
156
+ function buildClient(apiKey, workspaceId) {
157
+ const client = new SmartMemoryClient({
158
+ apiBaseUrl: baseUrl,
159
+ apiKey,
160
+ storage: 'memory',
161
+ fetchFn: fetchWithContract,
162
+ });
163
+ if (workspaceId) client.setTeamId(workspaceId);
164
+ return client;
165
+ }
166
+
167
+ /** @param {boolean} scoped send `X-Workspace-Id` (when one is configured) */
168
+ function sdk(scoped) {
47
169
  const key = cfg.apiKeyEnv ? process.env[cfg.apiKeyEnv] : undefined;
48
170
  if (!key) {
49
171
  throw new SmartmemoryHttpError('smartmemory: missing api key', 0);
50
172
  }
51
- return `Bearer ${key}`;
52
- }
53
-
54
- async function health() {
55
- try {
56
- const res = await withTimeout((signal) => fetch(`${baseUrl}/health`, { signal }));
57
- return { ok: res.ok, status: res.status };
58
- } catch {
59
- return { ok: false };
173
+ if (!cache || cache.key !== key) {
174
+ cache = {
175
+ key,
176
+ scoped: buildClient(key, cfg.workspaceId),
177
+ unscoped: buildClient(key, null),
178
+ };
60
179
  }
180
+ return scoped ? cache.scoped : cache.unscoped;
61
181
  }
62
182
 
63
- async function ingest(content, ctx) {
64
- const auth = authHeader(); // throws BEFORE any fetch when key is missing
65
- let res;
183
+ /**
184
+ * One request, with this module's three-way failure convention preserved:
185
+ * network failure → status 0, non-2xx → that status, 2xx we can't parse →
186
+ * `malformed-response`.
187
+ *
188
+ * @param {string} op operation name, for the error message
189
+ * @param {(api: object) => Promise<any>} send issues the call against a `BaseAPI`
190
+ * @param {{scoped?: boolean, nullOn404?: boolean}} [opts]
191
+ * @returns {Promise<object|null>} parsed body, or null for a tolerated 404
192
+ */
193
+ async function request(op, send, { scoped = true, nullOn404 = false } = {}) {
194
+ // Throws BEFORE any fetch when the key is missing.
195
+ const client = sdk(scoped);
66
196
  try {
67
- res = await withTimeout((signal) => fetch(`${baseUrl}/memory/ingest?mode=sync`, {
68
- method: 'POST',
69
- signal,
70
- headers: {
71
- 'Content-Type': 'application/json',
72
- Authorization: auth,
73
- },
74
- body: JSON.stringify({ content, context: ctx }),
75
- }));
197
+ return await send(client._api);
76
198
  } catch (err) {
77
- throw new SmartmemoryHttpError(`smartmemory: ingest request failed: ${err.message}`, 0);
199
+ // A miss is an answer, not a failure — but only where the caller said so.
200
+ // Blanket-tolerating 404 would turn "that item is gone" into a successful
201
+ // no-op on update and delete.
202
+ if (nullOn404 && err?.status === 404) return null;
203
+ throw asHttpError(op, err);
78
204
  }
79
- if (!res.ok) {
80
- throw new SmartmemoryHttpError(`smartmemory: ingest failed (HTTP ${res.status})`, res.status);
205
+ }
206
+
207
+ function asHttpError(op, err) {
208
+ if (err instanceof SmartmemoryHttpError) return err;
209
+ if (err?.detail?.composeKind === MALFORMED) {
210
+ return new SmartmemoryHttpError(
211
+ `smartmemory: ${op} returned a 2xx (HTTP ${err.status}) with a non-JSON body`,
212
+ err.status, MALFORMED,
213
+ );
81
214
  }
82
- let raw;
83
- try {
84
- raw = await res.json();
85
- } catch {
86
- throw new SmartmemoryHttpError(
87
- `smartmemory: ingest returned a 2xx (HTTP ${res.status}) with a non-JSON body`,
88
- res.status, 'malformed-response',
215
+ const status = typeof err?.status === 'number' ? err.status : 0;
216
+ if (status === 0) {
217
+ return new SmartmemoryHttpError(
218
+ `smartmemory: ${op} request failed: ${err?.message ?? 'unknown error'}`, 0,
89
219
  );
90
220
  }
91
- if (typeof raw?.status !== 'string') {
221
+ return new SmartmemoryHttpError(`smartmemory: ${op} failed (HTTP ${status})`, status);
222
+ }
223
+
224
+ /** Reject a 2xx that is valid JSON but missing what the caller will read.
225
+ * Same reasoning as the non-JSON check above: a response we cannot trust
226
+ * must not be indistinguishable from one we can. */
227
+ function requireShape(raw, op, ok, expected) {
228
+ if (!ok) {
92
229
  throw new SmartmemoryHttpError(
93
- `smartmemory: ingest returned a 2xx (HTTP ${res.status}) body missing a "status" field`,
94
- res.status, 'malformed-response',
230
+ `smartmemory: ${op} returned a 2xx body missing ${expected}`,
231
+ 200, MALFORMED,
95
232
  );
96
233
  }
234
+ return raw;
235
+ }
236
+
237
+ /** Liveness only. Deliberately NOT routed through the SDK: `/health` is
238
+ * unauthenticated, sits outside the API contract, and must answer rather than
239
+ * throw — including when no API key is configured at all. */
240
+ async function health() {
241
+ const controller = new AbortController();
242
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
243
+ try {
244
+ const res = await fetch(`${baseUrl}/health`, { signal: controller.signal });
245
+ return { ok: res.ok, status: res.status };
246
+ } catch {
247
+ return { ok: false };
248
+ } finally {
249
+ clearTimeout(timer);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Ingest one item through the full extraction pipeline.
255
+ *
256
+ * `?mode=sync` is explicit even though the route defaults to it: the caller
257
+ * depends on the returned `status` reflecting a completed write, and
258
+ * `mode=async` documents that it skips dedupe entirely. The body is exactly
259
+ * `{content, context}` — the SDK's `memories.ingest()` adds `extractor_name`
260
+ * and `profile_name`, which would change what the server does.
261
+ */
262
+ async function ingest(content, ctx) {
263
+ const raw = await request(
264
+ 'ingest',
265
+ (api) => api.post('/memory/ingest?mode=sync', { content, context: ctx }),
266
+ { scoped: false },
267
+ );
268
+ requireShape(raw, 'ingest', typeof raw?.status === 'string', 'a "status" field');
97
269
  const unchanged = raw.status === 'unchanged' || raw.unchanged === true;
98
270
  return { status: raw.status, unchanged, raw };
99
271
  }
100
272
 
273
+ /** Unscoped search over the key's default scope. See `searchItems` for the
274
+ * workspace-scoped form; the two are NOT interchangeable. */
101
275
  async function search(query, opts = {}) {
102
- const auth = authHeader();
103
- let res;
104
- try {
105
- res = await withTimeout((signal) => fetch(`${baseUrl}/memory/search`, {
106
- method: 'POST',
107
- signal,
108
- headers: {
109
- 'Content-Type': 'application/json',
110
- Authorization: auth,
111
- },
112
- body: JSON.stringify({ query, ...opts }),
113
- }));
114
- } catch (err) {
115
- throw new SmartmemoryHttpError(`smartmemory: search request failed: ${err.message}`, 0);
116
- }
117
- if (!res.ok) {
118
- throw new SmartmemoryHttpError(`smartmemory: search failed (HTTP ${res.status})`, res.status);
119
- }
120
- let raw;
121
- try {
122
- raw = await res.json();
123
- } catch {
276
+ const raw = await request(
277
+ 'search',
278
+ (api) => api.post('/memory/search', { query, ...opts }),
279
+ { scoped: false },
280
+ );
281
+ return requireShape(raw, 'search', Array.isArray(raw?.results), 'a "results" array');
282
+ }
283
+
284
+ // ── typed-record CRUD (COMP-FOH S01) ──────────────────────────────────────
285
+ //
286
+ // Six thin wrappers over the generic MemoryItem CRUD routes, so a storage
287
+ // provider can address items individually instead of going through `ingest`.
288
+ // They are DELIBERATELY dumb: they know the wire contract and nothing else.
289
+ // No handles, no record shapes, no kinds — that mapping belongs to the caller
290
+ // (lib/fluid/smartmemory-provider.js), and keeping it out of here is what lets
291
+ // these methods serve a non-fluid caller later.
292
+
293
+ /**
294
+ * Create one item. Returns the raw body; `id` is the new item_id.
295
+ *
296
+ * `usePipeline` defaults to **false**, which deliberately inverts the route's
297
+ * own default of true. The ingestion pipeline extracts graph entities from
298
+ * unstructured prose — correct for `ingest()`, wrong for a caller writing an
299
+ * already-structured record, where it would invent entities from the payload.
300
+ * A caller that wants the pipeline wants `ingest()` instead.
301
+ *
302
+ * Note the route answers **200, not 201** (COMP-FOH C9), so callers must not
303
+ * assert on 201.
304
+ */
305
+ async function createItem({ content, memoryType, metadata, usePipeline = false }) {
306
+ const raw = await request('createItem', (api) => api.post('/memory/add', {
307
+ content,
308
+ memory_type: memoryType,
309
+ metadata: metadata ?? {},
310
+ use_pipeline: usePipeline,
311
+ }));
312
+ return requireShape(raw, 'createItem', typeof raw?.id === 'string', 'an "id" string');
313
+ }
314
+
315
+ /** Fetch one item by item_id. **Returns `null` when it does not exist** —
316
+ * a lookup that misses is an ordinary answer, and making callers catch an
317
+ * exception for it would push try/catch into every read path. Every other
318
+ * failure still throws. */
319
+ async function getItem(itemId) {
320
+ const raw = await request(
321
+ 'getItem',
322
+ (api) => api.get(`/memory/${encodeURIComponent(itemId)}`),
323
+ { nullOn404: true },
324
+ );
325
+ if (raw === null) return null;
326
+ return requireShape(raw, 'getItem', raw && typeof raw === 'object', 'an object body');
327
+ }
328
+
329
+ /**
330
+ * List items, one page at a time. **The route defaults to `limit=50`** and
331
+ * this wrapper does not paginate for you — a caller enumerating everything
332
+ * must loop on `offset` until a short page or `total` (COMP-FOH C5).
333
+ *
334
+ * `metadataKey`/`metadataValue` filter on a single exact match and must be
335
+ * supplied together; the route 422s on half a pair, so that is refused here
336
+ * with a message that names the problem instead.
337
+ */
338
+ async function listItems({ limit, offset, order, metadataKey, metadataValue } = {}) {
339
+ if ((metadataKey === undefined) !== (metadataValue === undefined)) {
124
340
  throw new SmartmemoryHttpError(
125
- `smartmemory: search returned a 2xx (HTTP ${res.status}) with a non-JSON body`,
126
- res.status, 'malformed-response',
341
+ 'smartmemory: listItems requires metadataKey and metadataValue together, or neither',
342
+ 0,
127
343
  );
128
344
  }
129
- if (!Array.isArray(raw?.results)) {
345
+ const qs = new URLSearchParams();
346
+ if (limit !== undefined) qs.set('limit', String(limit));
347
+ if (offset !== undefined) qs.set('offset', String(offset));
348
+ if (order !== undefined) qs.set('order', order);
349
+ if (metadataKey !== undefined) {
350
+ qs.set('metadata_key', metadataKey);
351
+ qs.set('metadata_value', metadataValue);
352
+ }
353
+ const query = qs.toString();
354
+ const raw = await request('listItems', (api) => api.get(`/memory/list${query ? `?${query}` : ''}`));
355
+ return requireShape(raw, 'listItems', Array.isArray(raw?.items), 'an "items" array');
356
+ }
357
+
358
+ /**
359
+ * Update one item.
360
+ *
361
+ * Only `content`, `metadata` and `writeMode` are exposed. The route's other
362
+ * surface, `properties`, bypasses the metadata merge and hands over the full
363
+ * node property dict, which is the mass-assignment path the server's protected
364
+ * fields exist to guard — not something to expose from a general-purpose
365
+ * client.
366
+ *
367
+ * Two server behaviours the caller must already know about (COMP-FOH C7,
368
+ * C14b): the metadata merge is a **one-level spread**, so a top-level key you
369
+ * omit survives and one you send is replaced whole; and **this surface cannot
370
+ * clear** — `crud.py:1052` hard-merges metadata with no escape hatch, so a
371
+ * value you try to blank leaves the previous one in place behind a 200.
372
+ *
373
+ * Corrected 2026-08-05 (verified against live FalkorDB): that second point
374
+ * used to be filed as "PATCH cannot clear a property", which was too broad.
375
+ * Clearing DOES work — via the advanced `properties` surface with
376
+ * `write_mode: "replace"`, which issues a real `REMOVE` before `SET`. It is
377
+ * merge-only-ness of the CONVENIENCE surface that bites here, not a missing
378
+ * capability. That escape hatch is deliberately not offered from this client
379
+ * (see above): `properties` is the mass-assignment path. Callers needing to
380
+ * clear should reshape what they send, not reach for it.
381
+ */
382
+ async function updateItem(itemId, { content, metadata, writeMode } = {}) {
383
+ const body = {};
384
+ if (content !== undefined) body.content = content;
385
+ if (metadata !== undefined) body.metadata = metadata;
386
+ if (writeMode !== undefined) body.write_mode = writeMode;
387
+ if (Object.keys(body).length === 0) {
130
388
  throw new SmartmemoryHttpError(
131
- `smartmemory: search returned a 2xx (HTTP ${res.status}) body missing a "results" array`,
132
- res.status, 'malformed-response',
389
+ 'smartmemory: updateItem needs at least one of content or metadata',
390
+ 0,
133
391
  );
134
392
  }
135
- return raw;
393
+ return request('updateItem', (api) => api.patch(`/memory/${encodeURIComponent(itemId)}`, body));
394
+ }
395
+
396
+ /**
397
+ * Workspace-scoped semantic search.
398
+ *
399
+ * Separate from `search()` above, which is NOT interchangeable with it:
400
+ * `search()` sends no `X-Workspace-Id`, so it queries the key's default scope.
401
+ * A caller that stores into a configured workspace and reads back through
402
+ * `search()` would silently query somewhere else — the whole point of this
403
+ * method is that it goes through the scoped client like every other scoped call.
404
+ *
405
+ * **`channel_weights: {}` is sent on every request and is not optional.**
406
+ * Omitting the field makes the service fall back to the API key's stored
407
+ * agent recall-profile weights, and a zero weight there disables that
408
+ * retrieval channel outright. Recall behaviour would then depend on a
409
+ * per-key profile Compose does not manage and cannot see — and would change
410
+ * under it without warning. The empty dict is the service's documented way to
411
+ * say "use channel defaults, ignore the profile". This is also why the SDK's
412
+ * `memories.search()` is not used here: it does not send the field.
413
+ *
414
+ * @param {string} query free text
415
+ * @param {{topK?: number, memoryType?: string}} [opts]
416
+ * @returns {Promise<{results: object[]}>} raw service response
417
+ */
418
+ async function searchItems(query, { topK, memoryType } = {}) {
419
+ const body = { query, channel_weights: {} };
420
+ if (topK !== undefined) body.top_k = topK;
421
+ if (memoryType !== undefined) body.memory_type = memoryType;
422
+ const raw = await request('searchItems', (api) => api.post('/memory/search', body));
423
+ return requireShape(raw, 'searchItems', Array.isArray(raw?.results), 'a "results" array');
424
+ }
425
+
426
+ /**
427
+ * Contradiction detection over stored memory of ONE type (COMP-FOH FOH-3).
428
+ *
429
+ * Scoped, so it runs against the configured workspace like `searchItems`. Two
430
+ * details are load-bearing:
431
+ * - `memoryType` is the EXACT stored type to search — the service applies it
432
+ * as an equality filter (`search.py:123`), not a prefix or wildcard, so the
433
+ * caller sends one concrete `fluid_<kind>`. `"semantic"` (the route default)
434
+ * would match nothing of ours.
435
+ * - `timeoutMs` overrides the client's 3s default for this call only. With
436
+ * `useLlm` on, the service runs an LLM cascade over up to ~10 related facts
437
+ * and routinely exceeds 3s (`challenger.py:220`). Passing it as the post's
438
+ * options arg is how the deadline reaches `fetchWithContract` per-call.
439
+ *
440
+ * @param {string} assertion the text to challenge
441
+ * @param {{memoryType?: string, useLlm?: boolean, timeoutMs?: number}} [opts]
442
+ * @returns {Promise<object>} raw ChallengeResponse (has_conflicts, conflicts[], …)
443
+ */
444
+ async function challenge(assertion, { memoryType, useLlm = true, timeoutMs } = {}) {
445
+ const raw = await request('challenge', (api) => api.post(
446
+ '/memory/reasoning/challenge',
447
+ { assertion, memory_type: memoryType, use_llm: useLlm },
448
+ timeoutMs === undefined ? undefined : { timeoutMs },
449
+ ));
450
+ return requireShape(
451
+ raw, 'challenge',
452
+ typeof raw?.has_conflicts === 'boolean' && Array.isArray(raw?.conflicts),
453
+ 'a "has_conflicts" boolean and "conflicts" array',
454
+ );
455
+ }
456
+
457
+ /**
458
+ * A record's confidence + decay history in ONE envelope (COMP-FOH FOH-4).
459
+ *
460
+ * The shape check is deliberately COMPLETE: the provider classifies a
461
+ * destructive write's outcome from this envelope, so a shaped-but-partial
462
+ * 2xx (say, `history` present but `challenge_count` missing) must be refused
463
+ * here rather than allowed to misclassify a decay as landed or lost.
464
+ *
465
+ * @param {string} itemId
466
+ * @returns {Promise<object>} `{item_id, current_confidence, challenge_count,
467
+ * history, history_count}` — `current_confidence` reads the first-class
468
+ * field (CONFIDENCE-DECAY-FIELD-1), authoritative post-decay.
469
+ */
470
+ async function confidenceHistory(itemId) {
471
+ const raw = await request(
472
+ 'confidenceHistory',
473
+ (api) => api.get(`/memory/reasoning/confidence-history/${encodeURIComponent(itemId)}`),
474
+ );
475
+ return requireShape(
476
+ raw, 'confidenceHistory',
477
+ typeof raw?.item_id === 'string'
478
+ && Number.isFinite(raw?.current_confidence)
479
+ && Number.isInteger(raw?.challenge_count)
480
+ && Array.isArray(raw?.history)
481
+ && Number.isInteger(raw?.history_count),
482
+ 'the full envelope (item_id, current_confidence, challenge_count, history, history_count)',
483
+ );
484
+ }
485
+
486
+ /**
487
+ * Apply an explicit resolution to a contradiction (COMP-FOH FOH-4).
488
+ *
489
+ * Three flags are sent `false` EXPLICITLY because the route defaults ALL of
490
+ * them to true (`reasoning.py:65-68`): `auto_resolve` (the Wikipedia→LLM
491
+ * cascade), `use_wikipedia`, `use_llm`. v1 is deterministic or nothing.
492
+ *
493
+ * The response is returned raw but is NOT the source of truth for the decay:
494
+ * the server ignores `apply_decay`'s success and its `confidence` field is
495
+ * ambiguous, so the provider re-reads `confidenceHistory` to verify. The 8s
496
+ * default timeout covers the deterministic path's get + decay + update round
497
+ * trips — generous, because an abort here creates an ambiguous outcome the
498
+ * provider then has to reconcile.
499
+ *
500
+ * @param {{existingItemId: string, newFact: string, strategy: string, timeoutMs?: number}} args
501
+ * @returns {Promise<object>} raw ResolveResponse (auto_resolved, resolution, …)
502
+ */
503
+ async function resolveConflict({ existingItemId, newFact, strategy, timeoutMs }) {
504
+ const raw = await request('resolveConflict', (api) => api.post(
505
+ '/memory/reasoning/resolve',
506
+ {
507
+ existing_item_id: existingItemId,
508
+ new_fact: newFact,
509
+ auto_resolve: false,
510
+ strategy,
511
+ use_llm: false,
512
+ use_wikipedia: false,
513
+ },
514
+ { timeoutMs: timeoutMs ?? 8000 },
515
+ ));
516
+ return requireShape(
517
+ raw, 'resolveConflict',
518
+ Array.isArray(raw?.actions_taken),
519
+ 'an "actions_taken" array',
520
+ );
521
+ }
522
+
523
+ /**
524
+ * Write one graph edge between two items (COMP-FOH FOH-5).
525
+ *
526
+ * The route (`links.py:72`) ALWAYS answers `200 {status:"success", result}`,
527
+ * even when no edge was created: FalkorDB catches a write error and returns
528
+ * `false` (`falkordb.py:899,906`), which `SmartGraph` normalizes to
529
+ * `{edge_created:false}` (`smartgraph.py:394`). So `status:"success"` is not
530
+ * proof — the same never-trust-the-envelope hazard FOH-4 hit on `/resolve`.
531
+ * The real outcome is `result.edge_created`, and a mismatch of source/target/
532
+ * type would mean the server acted on something other than what we asked. Any
533
+ * of those is a FAILED write, surfaced as MALFORMED so the caller's retry/warn
534
+ * path fires rather than silently trusting a missing edge.
535
+ *
536
+ * @param {{sourceId: string, targetId: string, relationType: string, properties?: object}} args
537
+ * @returns {Promise<object>} the raw envelope (verified) on a real create
538
+ */
539
+ async function addEdge({ sourceId, targetId, relationType, properties }) {
540
+ const raw = await request('addEdge', (api) => api.post('/memory/edge', {
541
+ source_id: sourceId,
542
+ target_id: targetId,
543
+ relation_type: relationType,
544
+ properties: properties ?? {},
545
+ }));
546
+ const r = raw?.result;
547
+ const created = r?.edge_created === true
548
+ && String(r?.source_id) === String(sourceId)
549
+ && String(r?.target_id) === String(targetId)
550
+ && String(r?.edge_type) === String(relationType);
551
+ return requireShape(
552
+ raw, 'addEdge', created,
553
+ `result.edge_created===true for ${sourceId} -[${relationType}]-> ${targetId} `
554
+ + '(a 200 with edge_created:false is a failed write, not a success)',
555
+ );
556
+ }
557
+
558
+ /**
559
+ * Every graph neighbour of an item, both directions (COMP-FOH FOH-5).
560
+ *
561
+ * `GET /memory/{id}/neighbors` (`links.py:149`) walks `outgoing` and `incoming`
562
+ * separately and returns `direction` per neighbour — the ONLY read that
563
+ * preserves edge direction (`get_links` inverts it on incoming edges). It
564
+ * filters `HAS_VERSION` and returns no edge properties.
565
+ *
566
+ * **Throws on 404** (not `nullOn404`): a miss here is a target-deletion race
567
+ * the provider maps to `FluidRecordNotFound`, exactly as `conviction()` does —
568
+ * a null would be indistinguishable from "no neighbours".
569
+ *
570
+ * @param {string} itemId
571
+ * @returns {Promise<Array<{item_id: string, content: string, memory_type: string, link_type: string, direction: string}>>}
572
+ */
573
+ async function neighbors(itemId) {
574
+ const raw = await request(
575
+ 'neighbors',
576
+ (api) => api.get(`/memory/${encodeURIComponent(itemId)}/neighbors`),
577
+ );
578
+ requireShape(raw, 'neighbors', Array.isArray(raw?.neighbors), 'a "neighbors" array');
579
+ return raw.neighbors;
580
+ }
581
+
582
+ /** Delete one item. Requires the `delete:memories` scope, which the route
583
+ * derives from the HTTP method — a key without it fails only here, long
584
+ * after setup looked like it worked (COMP-FOH C8). */
585
+ async function deleteItem(itemId, { cleanupOrphans = false } = {}) {
586
+ const query = cleanupOrphans ? '?cleanup_orphans=true' : '';
587
+ return request(
588
+ 'deleteItem',
589
+ (api) => api.delete(`/memory/${encodeURIComponent(itemId)}${query}`),
590
+ );
591
+ }
592
+
593
+ // ── coordination primitives (SVC-ALLOC-1, SVC-LEASE-1) ────────────────────
594
+ //
595
+ // Straight delegation to the SDK's own clients. Nothing is re-modelled here:
596
+ // both carry a failure convention this module has no business second-guessing
597
+ // (an allocator has no benign failure; a lease distinguishes "someone else
598
+ // holds it" from "the coordinator did not answer"), and flattening either into
599
+ // SmartmemoryHttpError would erase exactly the distinction the caller needs.
600
+ // These therefore throw `APIError`, not `SmartmemoryHttpError`.
601
+
602
+ /**
603
+ * Create a decision (GOV-COMPOSE-SEAM-1 `canon-on-decisions` P2).
604
+ *
605
+ * Hand-rolled rather than routed through the SDK because `@smartmemory/sdk-js`
606
+ * has NO decisions surface at all; adding one is separate, unscoped work.
607
+ *
608
+ * Scoped, so the decision lands in the configured workspace alongside this
609
+ * project's ingest events rather than in the key's default scope.
610
+ *
611
+ * `source_type` and `context_snapshot` are only reachable as of the
612
+ * 2026-08-22 service change; against an older service they are silently
613
+ * ignored by FastAPI, which is why `writeJudgmentDecision` verifies the
614
+ * round trip rather than trusting the 200. Contract:
615
+ * smart-memory-docs/docs/features/GOV-COMPOSE-SEAM-1/decision-create-contract.json
616
+ */
617
+ async function createDecision(payload) {
618
+ const raw = await request(
619
+ 'createDecision',
620
+ (api) => api.post('/memory/decisions/create', payload),
621
+ );
622
+ return requireShape(
623
+ raw,
624
+ 'createDecision',
625
+ typeof raw?.decision_id === 'string',
626
+ 'a "decision_id" field',
627
+ );
628
+ }
629
+
630
+ /** Read one decision back. Used to VERIFY a write landed with its provenance
631
+ * intact, not merely that the service answered 200. */
632
+ async function getDecision(decisionId) {
633
+ return request(
634
+ 'getDecision',
635
+ (api) => api.get(`/memory/decisions/${encodeURIComponent(decisionId)}`),
636
+ { nullOn404: true },
637
+ );
638
+ }
639
+
640
+ /** Consume the next number in a workspace-scoped monotonic sequence. The
641
+ * caller owns the inclusive range [first, value]; unused numbers are LOST. */
642
+ async function allocateSequence(name, { floor, count } = {}) {
643
+ return sdk(true).sequences.allocate(name, { floor, count });
644
+ }
645
+
646
+ /** Read a sequence counter without consuming, or null if never allocated. */
647
+ async function peekSequence(name) {
648
+ return sdk(true).sequences.peek(name);
649
+ }
650
+
651
+ /** Acquire a lease, or null when a live holder definitively owns it. */
652
+ async function acquireLock(key, { ttlSeconds } = {}) {
653
+ return sdk(true).locks.acquire(key, { ttlSeconds });
654
+ }
655
+
656
+ /** Renew a lease, or null when this token definitively no longer owns it. */
657
+ async function renewLock(key, token, { ttlSeconds } = {}) {
658
+ return sdk(true).locks.renew(key, token, { ttlSeconds });
659
+ }
660
+
661
+ /** Release a lease. False when this token definitively no longer owns it. */
662
+ async function releaseLock(key, token) {
663
+ return sdk(true).locks.release(key, token);
136
664
  }
137
665
 
138
- return { health, ingest, search };
666
+ return {
667
+ health, ingest, search,
668
+ createItem, getItem, listItems, updateItem, deleteItem, searchItems, challenge,
669
+ confidenceHistory, resolveConflict,
670
+ addEdge, neighbors,
671
+ createDecision, getDecision,
672
+ allocateSequence, peekSequence,
673
+ acquireLock, renewLock, releaseLock,
674
+ };
139
675
  }