@smartmemory/compose 0.3.7 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.compose-deps.json +1 -13
- package/README.md +72 -5
- package/bin/compose.js +470 -351
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
- package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
- package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
- package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
- package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
- package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
- package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
- package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +9 -0
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +874 -170
- package/lib/canon-guard.js +28 -6
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +104 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +26 -13
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +3 -3
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +57 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -23
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +9 -1
- package/lib/judgment/store/records.js +1 -1
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +5 -1
- package/lib/judgment-writer.js +56 -2
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +52 -1
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +396 -199
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +271 -36
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +109 -23
- package/server/compose-mcp.js +88 -882
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +34 -2
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-PkZzHeMj.js +0 -894
- package/dist/assets/channel-qVK_qn4E.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
- package/dist/assets/clone-Pu3RyLUh.js +0 -1
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/mobile-BnXEOE3U.js +0 -17
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
- package/lib/staleness.js +0 -87
- 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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
10
|
-
*
|
|
11
|
-
* `status` for ingest, `results` for search). The latter
|
|
12
|
-
* `kind: 'malformed-response'` so callers can tell "the service said
|
|
13
|
-
* "the service said something we can't trust" if they want to, while
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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:
|
|
94
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
341
|
+
'smartmemory: listItems requires metadataKey and metadataValue together, or neither',
|
|
342
|
+
0,
|
|
127
343
|
);
|
|
128
344
|
}
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
389
|
+
'smartmemory: updateItem needs at least one of content or metadata',
|
|
390
|
+
0,
|
|
133
391
|
);
|
|
134
392
|
}
|
|
135
|
-
return
|
|
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 {
|
|
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
|
}
|