@tangleai/memory 0.20.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # @tangleai/memory
2
+
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Establish the coordinated 0.20.0 release with JavaScript and TypeScript declaration distributions, preserved public subpaths and JSON schemas, and verified Node and Bun consumers. Use published JarenJS 0.83.3 fixes without a consumer installation patch. Prepare versions before release commits, verify locally, push directly to main and publish CI-verified tarballs. Deploy the website independently from local Tangle workspace source with JarenJS packages from npm, verifying dependency sources and the live commit.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - @tangleai/core@0.20.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joham (jklarenbeek@gmail.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @tangleai/memory
2
+
3
+ Tangle AI memory policies — novelty gating, crystallization, contradiction resolution, outcome learning — over an injected store
4
+
5
+ Install with `npm install @tangleai/memory`. The npm distribution provides ESM JavaScript, TypeScript declarations, and the documented package subpaths for Node 24 and Bun 1.4 or newer.
6
+
7
+ See the [Tangle documentation](https://github.com/jklarenbeek/tangleai#readme) for architecture, examples, and runtime requirements. All public Tangle packages use one coordinated version.
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@tangleai/memory",
3
+ "version": "0.20.0",
4
+ "description": "Tangle AI memory policies — novelty gating, crystallization, contradiction resolution, outcome learning — over an injected store",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "import": "./src/index.js",
12
+ "default": "./src/index.js"
13
+ },
14
+ "./store": {
15
+ "types": "./src/store.d.ts",
16
+ "import": "./src/store.js",
17
+ "default": "./src/store.js"
18
+ },
19
+ "./ingest": {
20
+ "types": "./src/ingest.d.ts",
21
+ "import": "./src/ingest.js",
22
+ "default": "./src/ingest.js"
23
+ },
24
+ "./novelty": {
25
+ "types": "./src/novelty.d.ts",
26
+ "import": "./src/novelty.js",
27
+ "default": "./src/novelty.js"
28
+ },
29
+ "./crystallize": {
30
+ "types": "./src/crystallize.d.ts",
31
+ "import": "./src/crystallize.js",
32
+ "default": "./src/crystallize.js"
33
+ },
34
+ "./contradiction": {
35
+ "types": "./src/contradiction.d.ts",
36
+ "import": "./src/contradiction.js",
37
+ "default": "./src/contradiction.js"
38
+ },
39
+ "./outcome": {
40
+ "types": "./src/outcome.d.ts",
41
+ "import": "./src/outcome.js",
42
+ "default": "./src/outcome.js"
43
+ },
44
+ "./retrieval": {
45
+ "types": "./src/retrieval.d.ts",
46
+ "import": "./src/retrieval.js",
47
+ "default": "./src/retrieval.js"
48
+ },
49
+ "./package.json": "./package.json"
50
+ },
51
+ "engines": {
52
+ "node": ">=24"
53
+ },
54
+ "sideEffects": false,
55
+ "dependencies": {
56
+ "@jarenjs/ai": "0.83.3",
57
+ "@tangleai/core": "^0.20.0",
58
+ "@jarenjs/core": "0.83.3",
59
+ "@jarenjs/validate": "0.83.3"
60
+ },
61
+ "private": false,
62
+ "types": "./src/index.d.ts",
63
+ "files": [
64
+ "src/**/*.js",
65
+ "src/**/*.d.ts",
66
+ "schemas/**/*.json",
67
+ "README.md",
68
+ "LICENSE",
69
+ "CHANGELOG.md"
70
+ ],
71
+ "publishConfig": {
72
+ "access": "public",
73
+ "registry": "https://registry.npmjs.org/"
74
+ },
75
+ "repository": {
76
+ "type": "git",
77
+ "url": "git+https://github.com/jklarenbeek/tangleai.git",
78
+ "directory": "packages/memory"
79
+ }
80
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Contradiction resolution — ported from memflow `ContradictionModule`,
3
+ * with the LLM moved behind a seam.
4
+ *
5
+ * memflow called `llm.invoke()` inline and regex-scraped JSON out of the
6
+ * reply. Here the judge is INJECTED: any async function answering the
7
+ * verdict shape. The intended production judge is two lines of
8
+ * @jarenjs/ai — `createStructuredOutput({ client, schema:
9
+ * CONTRADICTION_VERDICT_SCHEMA })` over `contradictionMessages(a, b)` —
10
+ * which buys schema-constrained decoding and the bounded repair loop
11
+ * instead of a regex. The tests inject a table.
12
+ *
13
+ * Policy (unchanged): only pairs similar enough to be ABOUT the same
14
+ * thing (>= threshold, default 0.75) are worth judging; the most similar
15
+ * pairs are judged first; at most `maxPairs` LLM calls per pass. On a
16
+ * confirmed contradiction the OLDER record is marked superseded (never
17
+ * deleted — "what did we believe before" stays answerable). A judge
18
+ * failure skips the pair; it never kills the pass.
19
+ *
20
+ * Tolerance is not silence. A pass that skipped every pair because the
21
+ * judge was down and a pass that judged every pair and found nothing
22
+ * are different outcomes, and a measurement that cannot tell them apart
23
+ * cannot say what a threshold did — so the outcome counts both halves
24
+ * of both forks: every attempt is judged or failed, and every confirmed
25
+ * verdict is applied or skipped because its older record was already
26
+ * gone.
27
+ *
28
+ * Only a SYNTHESIZED resolution becomes a new record. When the judge
29
+ * answers with the newer record's own text (or nothing), the newer
30
+ * record IS the resolution — writing it again would content-address to
31
+ * the same id and overwrite the embedded fact with an un-embedded
32
+ * summary, which is how the winner disappears from ranked recall.
33
+ */
34
+ import type { JsonSchema, MemoryUnit } from '@tangleai/core/schemas/memory';
35
+ import type { MemoryStore } from './store.ts';
36
+ export declare const DEFAULT_CONTRADICTION_THRESHOLD = 0.75;
37
+ export declare const DEFAULT_MAX_PAIRS = 20;
38
+ /** What a judge must answer. Wire it to @jarenjs/ai `createStructuredOutput`
39
+ * as the `schema` and the repair loop enforces it for free. */
40
+ export declare const CONTRADICTION_VERDICT_SCHEMA: JsonSchema;
41
+ export interface ContradictionVerdict {
42
+ contradiction: boolean;
43
+ reason?: string;
44
+ resolution?: string;
45
+ }
46
+ export interface ChatMessage {
47
+ role: string;
48
+ content: string;
49
+ }
50
+ /**
51
+ * The judge's chat messages for one pair — kept beside the schema so the
52
+ * prompt and the contract cannot drift apart.
53
+ */
54
+ export declare function contradictionMessages(a: MemoryUnit, b: MemoryUnit): ChatMessage[];
55
+ export interface ContradictionPair {
56
+ a: MemoryUnit;
57
+ b: MemoryUnit;
58
+ similarity: number;
59
+ }
60
+ export interface ContradictionPlanOptions {
61
+ threshold?: number;
62
+ maxPairs?: number;
63
+ }
64
+ /** Select the pairs worth judging. Pure. */
65
+ export declare function planContradictionPairs(units: MemoryUnit[], options?: ContradictionPlanOptions): ContradictionPair[];
66
+ export interface ResolveOptions {
67
+ judge: (a: MemoryUnit, b: MemoryUnit) => Promise<ContradictionVerdict | null>;
68
+ /** RFC 3339. */
69
+ now: () => string;
70
+ }
71
+ export interface ResolveOutcome {
72
+ /** Planned pairs handed to the judge. */
73
+ attempted: number;
74
+ /** Pairs the judge answered. */
75
+ judged: number;
76
+ /** Judge calls that threw: the pair is skipped, the pass continues. */
77
+ judgeFailures: number;
78
+ /** Verdicts that confirmed a contradiction — applied or skipped. */
79
+ confirmed: number;
80
+ /** Confirmed contradictions written: the older record marked superseded. */
81
+ contradictions: number;
82
+ /** Confirmed contradictions whose older record was gone at write time. */
83
+ applicationSkips: number;
84
+ resolutions: MemoryUnit[];
85
+ }
86
+ /** Judge the planned pairs and resolve confirmed contradictions in the store. */
87
+ export declare function resolveContradictions(store: MemoryStore, pairs: ContradictionPair[], options: ResolveOptions): Promise<ResolveOutcome>;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Contradiction resolution — ported from memflow `ContradictionModule`,
3
+ * with the LLM moved behind a seam.
4
+ *
5
+ * memflow called `llm.invoke()` inline and regex-scraped JSON out of the
6
+ * reply. Here the judge is INJECTED: any async function answering the
7
+ * verdict shape. The intended production judge is two lines of
8
+ * @jarenjs/ai — `createStructuredOutput({ client, schema:
9
+ * CONTRADICTION_VERDICT_SCHEMA })` over `contradictionMessages(a, b)` —
10
+ * which buys schema-constrained decoding and the bounded repair loop
11
+ * instead of a regex. The tests inject a table.
12
+ *
13
+ * Policy (unchanged): only pairs similar enough to be ABOUT the same
14
+ * thing (>= threshold, default 0.75) are worth judging; the most similar
15
+ * pairs are judged first; at most `maxPairs` LLM calls per pass. On a
16
+ * confirmed contradiction the OLDER record is marked superseded (never
17
+ * deleted — "what did we believe before" stays answerable). A judge
18
+ * failure skips the pair; it never kills the pass.
19
+ *
20
+ * Tolerance is not silence. A pass that skipped every pair because the
21
+ * judge was down and a pass that judged every pair and found nothing
22
+ * are different outcomes, and a measurement that cannot tell them apart
23
+ * cannot say what a threshold did — so the outcome counts both halves
24
+ * of both forks: every attempt is judged or failed, and every confirmed
25
+ * verdict is applied or skipped because its older record was already
26
+ * gone.
27
+ *
28
+ * Only a SYNTHESIZED resolution becomes a new record. When the judge
29
+ * answers with the newer record's own text (or nothing), the newer
30
+ * record IS the resolution — writing it again would content-address to
31
+ * the same id and overwrite the embedded fact with an un-embedded
32
+ * summary, which is how the winner disappears from ranked recall.
33
+ */
34
+ import { cosineSimilarity } from '@jarenjs/core/vector';
35
+ import { createMemoryUnit } from "./ingest.js";
36
+ export const DEFAULT_CONTRADICTION_THRESHOLD = 0.75;
37
+ export const DEFAULT_MAX_PAIRS = 20;
38
+ /** What a judge must answer. Wire it to @jarenjs/ai `createStructuredOutput`
39
+ * as the `schema` and the repair loop enforces it for free. */
40
+ export const CONTRADICTION_VERDICT_SCHEMA = {
41
+ $id: 'https://tangleai.dev/schemas/contradiction-verdict.json',
42
+ type: 'object',
43
+ properties: {
44
+ contradiction: { type: 'boolean' },
45
+ reason: { type: 'string' },
46
+ resolution: { type: 'string' },
47
+ },
48
+ required: ['contradiction'],
49
+ additionalProperties: false,
50
+ };
51
+ /**
52
+ * The judge's chat messages for one pair — kept beside the schema so the
53
+ * prompt and the contract cannot drift apart.
54
+ */
55
+ export function contradictionMessages(a, b) {
56
+ return [
57
+ {
58
+ role: 'system',
59
+ content: 'You judge whether two memory records contradict each other. '
60
+ + 'Two records contradict when both cannot be true at once — differing detail or scope is not contradiction. '
61
+ + 'When they do contradict, state the reason and write a single resolution sentence that reflects the better-evidenced record.',
62
+ },
63
+ {
64
+ role: 'user',
65
+ content: `Record A (${a.at}): "${a.text}" (evidence: ${a.evidence})\n`
66
+ + `Record B (${b.at}): "${b.text}" (evidence: ${b.evidence})\n`
67
+ + 'Do these contradict?',
68
+ },
69
+ ];
70
+ }
71
+ /** Select the pairs worth judging. Pure. */
72
+ export function planContradictionPairs(units, options = {}) {
73
+ const threshold = options.threshold ?? DEFAULT_CONTRADICTION_THRESHOLD;
74
+ const maxPairs = options.maxPairs ?? DEFAULT_MAX_PAIRS;
75
+ const candidates = [];
76
+ const live = units.filter((u) => !u.supersededBy);
77
+ for (let i = 0; i < live.length; i++) {
78
+ for (let j = i + 1; j < live.length; j++) {
79
+ const a = live[i];
80
+ const b = live[j];
81
+ if (!a.embedding || !b.embedding)
82
+ continue;
83
+ const sim = cosineSimilarity(a.embedding, b.embedding);
84
+ if (sim >= threshold)
85
+ candidates.push({ a, b, similarity: sim });
86
+ }
87
+ }
88
+ candidates.sort((x, y) => y.similarity - x.similarity);
89
+ return candidates.slice(0, maxPairs);
90
+ }
91
+ /** Judge the planned pairs and resolve confirmed contradictions in the store. */
92
+ export async function resolveContradictions(store, pairs, options) {
93
+ let contradictions = 0;
94
+ let judged = 0;
95
+ let judgeFailures = 0;
96
+ let confirmed = 0;
97
+ let applicationSkips = 0;
98
+ const resolutions = [];
99
+ for (const pair of pairs) {
100
+ let verdict = null;
101
+ try {
102
+ verdict = await options.judge(pair.a, pair.b);
103
+ }
104
+ catch {
105
+ judgeFailures++; // a judge failure skips the pair, never the pass — and is counted
106
+ continue;
107
+ }
108
+ judged++;
109
+ if (!verdict?.contradiction)
110
+ continue;
111
+ confirmed++;
112
+ const older = pair.a.at <= pair.b.at ? pair.a : pair.b;
113
+ const newer = older === pair.a ? pair.b : pair.a;
114
+ const supersededAt = options.now();
115
+ const loser = await store.get(older.id);
116
+ if (!loser) {
117
+ applicationSkips++;
118
+ continue;
119
+ } // a prior pass or a host raced us
120
+ loser.supersededBy = newer.id;
121
+ loser.supersededAt = supersededAt;
122
+ loser.supersededReason = verdict.reason ?? 'contradiction detected';
123
+ await store.put(loser);
124
+ if (verdict.resolution !== undefined && verdict.resolution !== newer.text) {
125
+ const resolution = createMemoryUnit({
126
+ text: verdict.resolution,
127
+ evidence: `contradiction resolution of ${older.id} by ${newer.id}: ${verdict.reason ?? 'contradiction detected'}`,
128
+ tags: [...new Set([...older.tags, ...newer.tags, 'resolution'])],
129
+ at: supersededAt,
130
+ kind: 'summary',
131
+ });
132
+ await store.put(resolution);
133
+ resolutions.push(resolution);
134
+ }
135
+ contradictions++;
136
+ }
137
+ return { attempted: pairs.length, judged, judgeFailures, confirmed, contradictions, applicationSkips, resolutions };
138
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Crystallization — merge near-duplicate memories into canonical forms.
3
+ * Ported from memflow `CrystallizerModule`, split into the house shape:
4
+ * a PURE planner that decides what to merge, and an applier that executes
5
+ * the plan against a store. memflow fused deciding and Cypher-executing
6
+ * in one method, which meant the merge policy could only ever be tested
7
+ * against a live Memgraph; here the plan is a value you can assert on.
8
+ *
9
+ * Policy (unchanged from memflow):
10
+ * - pairwise similarity >= threshold (default 0.92) marks a duplicate pair
11
+ * - the higher-confidence record survives; ties keep the first
12
+ * - a record participates in at most one merge per pass — a chain
13
+ * A~B~C collapses over successive passes, not in one ambiguous step
14
+ * - only records embedded by the same identity are compared (the
15
+ * jarenjs rule: vectors from two models never rank against each
16
+ * other); the metric is `@jarenjs/core/vector`'s cosine
17
+ *
18
+ * Application details that changed, deliberately:
19
+ * - the survivor's confidence rises by `boost` (0.05), clamped to
20
+ * [floor, 1]; the floor keeps a record demotable but never erasable
21
+ * - the absorbed record's id lands in `mergedFrom` (provenance — the
22
+ * Cypher version kept `originalIds`, same idea)
23
+ * - tags are unioned, relations are concatenated with per-target
24
+ * weight summing, evidence strings are joined — nothing is dropped
25
+ * - the absorbed record is DELETED from the store; its id in
26
+ * `mergedFrom` is the tombstone
27
+ *
28
+ * The applier counts what it planned as well as what it did: a merge
29
+ * whose keep or remove record vanished between planning and writing is
30
+ * skipped rather than guessed, and that skip is a value, because a pass
31
+ * that merged nothing because nothing matched and a pass that merged
32
+ * nothing because every planned record had been raced away are
33
+ * different outcomes.
34
+ */
35
+ import type { MemoryUnit } from '@tangleai/core/schemas/memory';
36
+ import type { MemoryStore } from './store.ts';
37
+ export declare const DEFAULT_CRYSTALLIZE_THRESHOLD = 0.92;
38
+ export declare const CONFIDENCE_BOOST = 0.05;
39
+ export declare const CONFIDENCE_FLOOR = 0.1;
40
+ export interface CrystallizeMerge {
41
+ keepId: string;
42
+ removeId: string;
43
+ similarity: number;
44
+ }
45
+ export interface CrystallizePlan {
46
+ merges: CrystallizeMerge[];
47
+ examined: number;
48
+ }
49
+ export interface CrystallizeOptions {
50
+ threshold?: number;
51
+ }
52
+ /** Decide which records to merge. Pure. */
53
+ export declare function planCrystallization(units: MemoryUnit[], options?: CrystallizeOptions): CrystallizePlan;
54
+ export interface ApplyOptions {
55
+ /** Returns RFC 3339 — injected, like every clock in the suite. */
56
+ now: () => string;
57
+ }
58
+ export interface CrystallizeOutcome {
59
+ /** Merges the plan asked for. */
60
+ planned: number;
61
+ /** Merges written. */
62
+ crystallized: number;
63
+ /** Planned merges whose keep or remove record was gone at write time. */
64
+ applicationSkips: number;
65
+ }
66
+ /** Execute a crystallization plan against a store. */
67
+ export declare function applyCrystallization(store: MemoryStore, plan: CrystallizePlan, options: ApplyOptions): Promise<CrystallizeOutcome>;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Crystallization — merge near-duplicate memories into canonical forms.
3
+ * Ported from memflow `CrystallizerModule`, split into the house shape:
4
+ * a PURE planner that decides what to merge, and an applier that executes
5
+ * the plan against a store. memflow fused deciding and Cypher-executing
6
+ * in one method, which meant the merge policy could only ever be tested
7
+ * against a live Memgraph; here the plan is a value you can assert on.
8
+ *
9
+ * Policy (unchanged from memflow):
10
+ * - pairwise similarity >= threshold (default 0.92) marks a duplicate pair
11
+ * - the higher-confidence record survives; ties keep the first
12
+ * - a record participates in at most one merge per pass — a chain
13
+ * A~B~C collapses over successive passes, not in one ambiguous step
14
+ * - only records embedded by the same identity are compared (the
15
+ * jarenjs rule: vectors from two models never rank against each
16
+ * other); the metric is `@jarenjs/core/vector`'s cosine
17
+ *
18
+ * Application details that changed, deliberately:
19
+ * - the survivor's confidence rises by `boost` (0.05), clamped to
20
+ * [floor, 1]; the floor keeps a record demotable but never erasable
21
+ * - the absorbed record's id lands in `mergedFrom` (provenance — the
22
+ * Cypher version kept `originalIds`, same idea)
23
+ * - tags are unioned, relations are concatenated with per-target
24
+ * weight summing, evidence strings are joined — nothing is dropped
25
+ * - the absorbed record is DELETED from the store; its id in
26
+ * `mergedFrom` is the tombstone
27
+ *
28
+ * The applier counts what it planned as well as what it did: a merge
29
+ * whose keep or remove record vanished between planning and writing is
30
+ * skipped rather than guessed, and that skip is a value, because a pass
31
+ * that merged nothing because nothing matched and a pass that merged
32
+ * nothing because every planned record had been raced away are
33
+ * different outcomes.
34
+ */
35
+ import { cosineSimilarity } from '@jarenjs/core/vector';
36
+ import { sameIdentity } from '@jarenjs/ai';
37
+ export const DEFAULT_CRYSTALLIZE_THRESHOLD = 0.92;
38
+ export const CONFIDENCE_BOOST = 0.05;
39
+ export const CONFIDENCE_FLOOR = 0.1;
40
+ /** Decide which records to merge. Pure. */
41
+ export function planCrystallization(units, options = {}) {
42
+ const threshold = options.threshold ?? DEFAULT_CRYSTALLIZE_THRESHOLD;
43
+ const candidates = [];
44
+ for (let i = 0; i < units.length; i++) {
45
+ for (let j = i + 1; j < units.length; j++) {
46
+ const a = units[i];
47
+ const b = units[j];
48
+ if (!sameIdentity(a.embeddedBy, b.embeddedBy))
49
+ continue;
50
+ if (a.supersededBy || b.supersededBy)
51
+ continue;
52
+ const sim = cosineSimilarity(a.embedding, b.embedding);
53
+ if (sim >= threshold) {
54
+ const keep = (a.confidence ?? 0.5) >= (b.confidence ?? 0.5) ? a : b;
55
+ const remove = keep === a ? b : a;
56
+ candidates.push({ keepId: keep.id, removeId: remove.id, similarity: sim });
57
+ }
58
+ }
59
+ }
60
+ // one merge per record per pass
61
+ const taken = new Set();
62
+ const merges = [];
63
+ for (const m of candidates) {
64
+ if (taken.has(m.keepId) || taken.has(m.removeId))
65
+ continue;
66
+ taken.add(m.keepId);
67
+ taken.add(m.removeId);
68
+ merges.push(m);
69
+ }
70
+ return { merges, examined: units.length };
71
+ }
72
+ /** Execute a crystallization plan against a store. */
73
+ export async function applyCrystallization(store, plan, options) {
74
+ let crystallized = 0;
75
+ let applicationSkips = 0;
76
+ for (const merge of plan.merges) {
77
+ const keep = await store.get(merge.keepId);
78
+ const remove = await store.get(merge.removeId);
79
+ if (!keep || !remove) {
80
+ applicationSkips++;
81
+ continue;
82
+ } // a prior merge or a host raced us; skip, don't guess
83
+ keep.confidence = Math.min(1, Math.max(CONFIDENCE_FLOOR, (keep.confidence ?? 0.5) + CONFIDENCE_BOOST));
84
+ keep.at = options.now();
85
+ keep.mergedFrom = [...(keep.mergedFrom ?? []), remove.id, ...(remove.mergedFrom ?? [])];
86
+ keep.tags = [...new Set([...keep.tags, ...remove.tags])];
87
+ if (remove.evidence && !keep.evidence.includes(remove.evidence)) {
88
+ keep.evidence = `${keep.evidence}; ${remove.evidence}`;
89
+ }
90
+ if (remove.relations?.length) {
91
+ const relations = keep.relations ? [...keep.relations] : [];
92
+ for (const rel of remove.relations) {
93
+ const existing = relations.find((r) => r.target === rel.target && r.relType === rel.relType);
94
+ if (existing)
95
+ existing.weight = (existing.weight ?? 0.5) + (rel.weight ?? 0.5);
96
+ else
97
+ relations.push({ ...rel });
98
+ }
99
+ keep.relations = relations;
100
+ }
101
+ await store.put(keep);
102
+ await store.delete(remove.id);
103
+ crystallized++;
104
+ }
105
+ return { planned: plan.merges.length, crystallized, applicationSkips };
106
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /** @tangleai/memory barrel. */
2
+ export { createMemoryUnitStore } from './store.ts';
3
+ export type { MemoryStore, MemoryUnitStoreOptions } from './store.ts';
4
+ export { createMemoryUnit, memoryId } from './ingest.ts';
5
+ export type { MemoryUnitInput } from './ingest.ts';
6
+ export { noveltyGate, DEFAULT_NOVELTY_THRESHOLD } from './novelty.ts';
7
+ export type { NoveltyOptions, NoveltyOutcome } from './novelty.ts';
8
+ export { planCrystallization, applyCrystallization, DEFAULT_CRYSTALLIZE_THRESHOLD, CONFIDENCE_BOOST, CONFIDENCE_FLOOR, } from './crystallize.ts';
9
+ export type { CrystallizeMerge, CrystallizePlan, CrystallizeOptions, CrystallizeOutcome } from './crystallize.ts';
10
+ export { planContradictionPairs, resolveContradictions, contradictionMessages, CONTRADICTION_VERDICT_SCHEMA, DEFAULT_CONTRADICTION_THRESHOLD, DEFAULT_MAX_PAIRS, } from './contradiction.ts';
11
+ export type { ContradictionVerdict, ContradictionPair, ChatMessage, ResolveOptions, ResolveOutcome, } from './contradiction.ts';
12
+ export { applyOutcome, outcomeAdjustment, DEFAULT_OUTCOME_OPTIONS } from './outcome.ts';
13
+ export type { OutcomeOptions, ApplyOutcomeResult } from './outcome.ts';
14
+ export { rankByEmbedding, recallByEmbedding } from './retrieval.ts';
15
+ export type { RankOptions, RankedMemory, RankedRecall } from './retrieval.ts';
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /** @tangleai/memory barrel. */
2
+ export { createMemoryUnitStore } from "./store.js";
3
+ export { createMemoryUnit, memoryId } from "./ingest.js";
4
+ export { noveltyGate, DEFAULT_NOVELTY_THRESHOLD } from "./novelty.js";
5
+ export { planCrystallization, applyCrystallization, DEFAULT_CRYSTALLIZE_THRESHOLD, CONFIDENCE_BOOST, CONFIDENCE_FLOOR, } from "./crystallize.js";
6
+ export { planContradictionPairs, resolveContradictions, contradictionMessages, CONTRADICTION_VERDICT_SCHEMA, DEFAULT_CONTRADICTION_THRESHOLD, DEFAULT_MAX_PAIRS, } from "./contradiction.js";
7
+ export { applyOutcome, outcomeAdjustment, DEFAULT_OUTCOME_OPTIONS } from "./outcome.js";
8
+ export { rankByEmbedding, recallByEmbedding } from "./retrieval.js";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Turning raw observations into memory units.
3
+ *
4
+ * Ids are content-addressed — `m-<fnv1a(text)>-<length>` via the same
5
+ * `hashContent` the @jarenjs/ai recall slots use — so ingesting the same
6
+ * observation twice produces the same id and the second write is an
7
+ * overwrite, not a duplicate. Idempotence by construction beats
8
+ * deduplication by policy; the crystallizer then only has to handle
9
+ * NEAR-duplicates, which no hash can catch.
10
+ */
11
+ import type { EmbeddedBy, MemoryKind, MemoryUnit } from '@tangleai/core/schemas/memory';
12
+ export declare function memoryId(text: string): string;
13
+ export interface MemoryUnitInput {
14
+ text: string;
15
+ /** Where this came from — required at the call site, not defaulted:
16
+ * the jarenjs rule that an unevidenced memory is a guess starts here,
17
+ * where it is cheapest to enforce. */
18
+ evidence: string;
19
+ /** RFC 3339 timestamp (injected, never Date.now here). */
20
+ at: string;
21
+ tags?: string[];
22
+ kind?: MemoryKind;
23
+ /** The vector and its identity travel together, or not at all. */
24
+ embedding?: number[];
25
+ embeddedBy?: EmbeddedBy;
26
+ confidence?: number;
27
+ }
28
+ export declare function createMemoryUnit(input: MemoryUnitInput): MemoryUnit;
package/src/ingest.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Turning raw observations into memory units.
3
+ *
4
+ * Ids are content-addressed — `m-<fnv1a(text)>-<length>` via the same
5
+ * `hashContent` the @jarenjs/ai recall slots use — so ingesting the same
6
+ * observation twice produces the same id and the second write is an
7
+ * overwrite, not a duplicate. Idempotence by construction beats
8
+ * deduplication by policy; the crystallizer then only has to handle
9
+ * NEAR-duplicates, which no hash can catch.
10
+ */
11
+ import { hashContent } from '@jarenjs/core/string';
12
+ export function memoryId(text) {
13
+ return `m-${hashContent(text)}-${text.length}`;
14
+ }
15
+ export function createMemoryUnit(input) {
16
+ const unit = {
17
+ id: memoryId(input.text),
18
+ text: input.text,
19
+ evidence: input.evidence,
20
+ tags: input.tags ?? [],
21
+ at: input.at,
22
+ kind: input.kind ?? 'fact',
23
+ };
24
+ if (input.embedding !== undefined && input.embeddedBy !== undefined) {
25
+ unit.embedding = input.embedding;
26
+ unit.embeddedBy = { ...input.embeddedBy };
27
+ }
28
+ if (input.confidence !== undefined)
29
+ unit.confidence = input.confidence;
30
+ return unit;
31
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Novelty gate — LightMem's Tier-1 sensory filter (arXiv 2510.18866),
3
+ * ported from memflow `NoveltyGateModule` as a pure function.
4
+ *
5
+ * A candidate passes if it is sufficiently DIFFERENT from every existing
6
+ * memory and from every candidate already admitted in this batch (the
7
+ * batch self-check is what stops ten copies of the same observation from
8
+ * all being "novel against the store"). A candidate without an embedding
9
+ * passes — novelty cannot be measured, and silently dropping what we
10
+ * cannot measure would bias the store toward whatever the embedder was
11
+ * given first. For the same reason a candidate is only measured against
12
+ * records embedded by the SAME identity (`embeddedBy`): vectors from two
13
+ * models compare into plausible garbage, and jarenjs's rule is that they
14
+ * are never compared — so a record from another embedder is, to this
15
+ * candidate, unmeasurable.
16
+ *
17
+ * The metric is `cosineSimilarity` from `@jarenjs/core/vector` — the one
18
+ * the suite ranks by, and the one under which the pipeline's normalized
19
+ * vectors agree with dot and Euclidean in rank anyway.
20
+ *
21
+ * Pure: no store, no clock, no log. The caller applies the result.
22
+ */
23
+ import type { MemoryUnit } from '@tangleai/core/schemas/memory';
24
+ export declare const DEFAULT_NOVELTY_THRESHOLD = 0.75;
25
+ export interface NoveltyOptions {
26
+ threshold?: number;
27
+ }
28
+ export interface NoveltyOutcome {
29
+ novel: MemoryUnit[];
30
+ filtered: MemoryUnit[];
31
+ }
32
+ export declare function noveltyGate(candidates: MemoryUnit[], existing: MemoryUnit[], options?: NoveltyOptions): NoveltyOutcome;
package/src/novelty.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Novelty gate — LightMem's Tier-1 sensory filter (arXiv 2510.18866),
3
+ * ported from memflow `NoveltyGateModule` as a pure function.
4
+ *
5
+ * A candidate passes if it is sufficiently DIFFERENT from every existing
6
+ * memory and from every candidate already admitted in this batch (the
7
+ * batch self-check is what stops ten copies of the same observation from
8
+ * all being "novel against the store"). A candidate without an embedding
9
+ * passes — novelty cannot be measured, and silently dropping what we
10
+ * cannot measure would bias the store toward whatever the embedder was
11
+ * given first. For the same reason a candidate is only measured against
12
+ * records embedded by the SAME identity (`embeddedBy`): vectors from two
13
+ * models compare into plausible garbage, and jarenjs's rule is that they
14
+ * are never compared — so a record from another embedder is, to this
15
+ * candidate, unmeasurable.
16
+ *
17
+ * The metric is `cosineSimilarity` from `@jarenjs/core/vector` — the one
18
+ * the suite ranks by, and the one under which the pipeline's normalized
19
+ * vectors agree with dot and Euclidean in rank anyway.
20
+ *
21
+ * Pure: no store, no clock, no log. The caller applies the result.
22
+ */
23
+ import { cosineSimilarity } from '@jarenjs/core/vector';
24
+ import { sameIdentity } from '@jarenjs/ai';
25
+ export const DEFAULT_NOVELTY_THRESHOLD = 0.75;
26
+ export function noveltyGate(candidates, existing, options = {}) {
27
+ const threshold = options.threshold ?? DEFAULT_NOVELTY_THRESHOLD;
28
+ const novel = [];
29
+ const filtered = [];
30
+ const duplicates = (unit, against) => {
31
+ for (const other of against) {
32
+ if (!sameIdentity(unit.embeddedBy, other.embeddedBy))
33
+ continue;
34
+ if (cosineSimilarity(unit.embedding, other.embedding) >= threshold)
35
+ return true;
36
+ }
37
+ return false;
38
+ };
39
+ for (const unit of candidates) {
40
+ if (unit.embedding === undefined || unit.embeddedBy === undefined) {
41
+ novel.push(unit);
42
+ continue;
43
+ }
44
+ if (duplicates(unit, existing) || duplicates(unit, novel)) {
45
+ filtered.push(unit);
46
+ }
47
+ else {
48
+ novel.push(unit);
49
+ }
50
+ }
51
+ return { novel, filtered };
52
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Outcome learning — ported from memflow `OutcomeLearnerModule`.
3
+ *
4
+ * The one idea worth keeping from the "Karpathy loop": confidence moves
5
+ * on GROUND TRUTH, not on the model's opinion of itself. A host files an
6
+ * `OutcomeReport` after acting on recalled memories — the report carries
7
+ * evidence of what actually happened — and the cited memories move:
8
+ * success boosts, failure penalizes (harder than success boosts, which
9
+ * is the asymmetry that makes the loop conservative), partial nudges.
10
+ * Confidence is clamped to [floor, max]: a memory can be discredited
11
+ * down to the floor but never silently erased by arithmetic — deletion
12
+ * is a separate, auditable decision.
13
+ */
14
+ import type { OutcomeReport } from '@tangleai/core/schemas/memory';
15
+ import type { MemoryStore } from './store.ts';
16
+ export interface OutcomeOptions {
17
+ successBoost: number;
18
+ failurePenalty: number;
19
+ partialBoost: number;
20
+ minConfidence: number;
21
+ maxConfidence: number;
22
+ }
23
+ export declare const DEFAULT_OUTCOME_OPTIONS: OutcomeOptions;
24
+ /** The signed confidence delta for an outcome. Pure. */
25
+ export declare function outcomeAdjustment(outcome: OutcomeReport['outcome'], options?: OutcomeOptions): number;
26
+ export interface ApplyOutcomeResult {
27
+ adjusted: number;
28
+ adjustment: number;
29
+ missing: string[];
30
+ }
31
+ /** Apply an outcome report to the store. */
32
+ export declare function applyOutcome(store: MemoryStore, report: OutcomeReport, config?: {
33
+ options?: OutcomeOptions;
34
+ }): Promise<ApplyOutcomeResult>;
package/src/outcome.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Outcome learning — ported from memflow `OutcomeLearnerModule`.
3
+ *
4
+ * The one idea worth keeping from the "Karpathy loop": confidence moves
5
+ * on GROUND TRUTH, not on the model's opinion of itself. A host files an
6
+ * `OutcomeReport` after acting on recalled memories — the report carries
7
+ * evidence of what actually happened — and the cited memories move:
8
+ * success boosts, failure penalizes (harder than success boosts, which
9
+ * is the asymmetry that makes the loop conservative), partial nudges.
10
+ * Confidence is clamped to [floor, max]: a memory can be discredited
11
+ * down to the floor but never silently erased by arithmetic — deletion
12
+ * is a separate, auditable decision.
13
+ */
14
+ export const DEFAULT_OUTCOME_OPTIONS = {
15
+ successBoost: 0.15,
16
+ failurePenalty: 0.25,
17
+ partialBoost: 0.05,
18
+ minConfidence: 0.1,
19
+ maxConfidence: 1.0,
20
+ };
21
+ /** The signed confidence delta for an outcome. Pure. */
22
+ export function outcomeAdjustment(outcome, options = DEFAULT_OUTCOME_OPTIONS) {
23
+ switch (outcome) {
24
+ case 'success': return options.successBoost;
25
+ case 'failure': return -options.failurePenalty;
26
+ case 'partial': return options.partialBoost;
27
+ default: return 0;
28
+ }
29
+ }
30
+ /** Apply an outcome report to the store. */
31
+ export async function applyOutcome(store, report, config = {}) {
32
+ const options = config.options ?? DEFAULT_OUTCOME_OPTIONS;
33
+ const adjustment = outcomeAdjustment(report.outcome, options);
34
+ let adjusted = 0;
35
+ const missing = [];
36
+ for (const id of report.memoryIds) {
37
+ const unit = await store.get(id);
38
+ if (!unit) {
39
+ missing.push(id); // reported, not swallowed — a report citing a
40
+ continue; // vanished memory is a fact the host should see
41
+ }
42
+ unit.confidence = Math.min(options.maxConfidence, Math.max(options.minConfidence, (unit.confidence ?? 0.5) + adjustment));
43
+ unit.at = report.at;
44
+ await store.put(unit);
45
+ adjusted++;
46
+ }
47
+ return { adjusted, adjustment, missing };
48
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Embedding-ranked retrieval over memory units.
3
+ *
4
+ * This is Tangle's ranker over its OWN store of full units, beside the
5
+ * @jarenjs/ai ledger's `recall({ near })` (which ranks the mirrored
6
+ * ledger records the same way, through the same kernels). Superseded
7
+ * records never surface — they exist for audit, not for recall. Records
8
+ * without embeddings never surface either, which is a real bias (see
9
+ * novelty.ts for the opposite choice on admission) — an un-embedded
10
+ * record can be ADMITTED honestly but cannot be RANKED honestly, so it
11
+ * waits for an embed pass rather than polluting the ranking with a fake
12
+ * score.
13
+ *
14
+ * The identity rule is the ledger's: a vector never ranks against one
15
+ * from another model. Given the query embedder's `identity`, only
16
+ * records embedded by it are ranked; the rest are reported in
17
+ * `skipped`, never scored and never hidden. Without an identity every
18
+ * embedded record is ranked — the caller has declared that it knows
19
+ * what it is comparing.
20
+ *
21
+ * The metric is `cosineSimilarity` from `@jarenjs/core/vector`: the one
22
+ * the suite ranks by, higher-is-better, 0 for a malformed pair.
23
+ */
24
+ import { type Vector } from '@jarenjs/core/vector';
25
+ import type { EmbeddedBy, MemoryUnit } from '@tangleai/core/schemas/memory';
26
+ export interface RankOptions {
27
+ k?: number;
28
+ minScore?: number;
29
+ /** The query embedder's identity; records embedded by another are skipped. */
30
+ identity?: EmbeddedBy;
31
+ }
32
+ export interface RankedMemory {
33
+ unit: MemoryUnit;
34
+ score: number;
35
+ }
36
+ export interface RankedRecall {
37
+ /** Best first, at most `k`. */
38
+ ranked: RankedMemory[];
39
+ /** Live records that carry no vector, or a vector from another identity. */
40
+ skipped: number;
41
+ }
42
+ /**
43
+ * Rank the live, comparably-embedded units against a query vector and
44
+ * report what could not be ranked. `queryEmbedding` may be the
45
+ * `Float32Array` an embedder answers or the `number[]` a record stores.
46
+ */
47
+ export declare function recallByEmbedding(units: MemoryUnit[], queryEmbedding: Vector, options?: RankOptions): RankedRecall;
48
+ /** Best first, at most `k` — `recallByEmbedding` without the skip report. */
49
+ export declare function rankByEmbedding(units: MemoryUnit[], queryEmbedding: Vector, options?: RankOptions): RankedMemory[];
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Embedding-ranked retrieval over memory units.
3
+ *
4
+ * This is Tangle's ranker over its OWN store of full units, beside the
5
+ * @jarenjs/ai ledger's `recall({ near })` (which ranks the mirrored
6
+ * ledger records the same way, through the same kernels). Superseded
7
+ * records never surface — they exist for audit, not for recall. Records
8
+ * without embeddings never surface either, which is a real bias (see
9
+ * novelty.ts for the opposite choice on admission) — an un-embedded
10
+ * record can be ADMITTED honestly but cannot be RANKED honestly, so it
11
+ * waits for an embed pass rather than polluting the ranking with a fake
12
+ * score.
13
+ *
14
+ * The identity rule is the ledger's: a vector never ranks against one
15
+ * from another model. Given the query embedder's `identity`, only
16
+ * records embedded by it are ranked; the rest are reported in
17
+ * `skipped`, never scored and never hidden. Without an identity every
18
+ * embedded record is ranked — the caller has declared that it knows
19
+ * what it is comparing.
20
+ *
21
+ * The metric is `cosineSimilarity` from `@jarenjs/core/vector`: the one
22
+ * the suite ranks by, higher-is-better, 0 for a malformed pair.
23
+ */
24
+ import { cosineSimilarity } from '@jarenjs/core/vector';
25
+ import { sameIdentity } from '@jarenjs/ai';
26
+ /**
27
+ * Rank the live, comparably-embedded units against a query vector and
28
+ * report what could not be ranked. `queryEmbedding` may be the
29
+ * `Float32Array` an embedder answers or the `number[]` a record stores.
30
+ */
31
+ export function recallByEmbedding(units, queryEmbedding, options = {}) {
32
+ const k = options.k ?? 5;
33
+ const minScore = options.minScore ?? 0;
34
+ const identity = options.identity;
35
+ const scored = [];
36
+ let skipped = 0;
37
+ for (const unit of units) {
38
+ if (unit.supersededBy !== undefined)
39
+ continue;
40
+ const comparable = unit.embedding !== undefined && unit.embeddedBy !== undefined
41
+ && (identity === undefined || sameIdentity(unit.embeddedBy, identity));
42
+ if (!comparable) {
43
+ skipped++;
44
+ continue;
45
+ }
46
+ scored.push({ unit, score: cosineSimilarity(unit.embedding, queryEmbedding) });
47
+ }
48
+ const ranked = scored
49
+ .filter((s) => s.score >= minScore)
50
+ .sort((a, b) => b.score - a.score)
51
+ .slice(0, k);
52
+ return { ranked, skipped };
53
+ }
54
+ /** Best first, at most `k` — `recallByEmbedding` without the skip report. */
55
+ export function rankByEmbedding(units, queryEmbedding, options = {}) {
56
+ return recallByEmbedding(units, queryEmbedding, options).ranked;
57
+ }
package/src/store.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The memory store seam and its default in-memory implementation.
3
+ *
4
+ * The contract is four async methods — `get`, `put`, `delete`, `list` —
5
+ * the same injection shape as the @jarenjs/ai ledger's storage seam, one
6
+ * level up: this store holds validated memory UNITS, not raw strings.
7
+ * A host backs it with SQLite, OPFS, a graph database, whatever; the
8
+ * policies in this package only ever see the contract.
9
+ *
10
+ * Every `put` is validated against `MEMORY_UNIT_SCHEMA`. The rule is the
11
+ * ledger's rule: a malformed write is rejected at the boundary, because
12
+ * a store that accepts junk makes every later policy pass unsound.
13
+ * Records are JSON-cloned on the way in and out, so no caller holds a
14
+ * live reference into the store — mutation happens through `put`, or
15
+ * not at all.
16
+ *
17
+ * TS note: `JarenValidator<true>` (inferred from `collectErrors: true`)
18
+ * makes `compile()` return a collector whose outcome is a typed
19
+ * `{ valid, errors }` — the shape-reading dance the JS version did is
20
+ * carried by the published d.ts now.
21
+ */
22
+ import { JarenValidator } from '@jarenjs/validate';
23
+ import { type MemoryUnit } from '@tangleai/core/schemas/memory';
24
+ export interface MemoryStore {
25
+ get(id: string): Promise<MemoryUnit | undefined>;
26
+ put(unit: MemoryUnit): Promise<void>;
27
+ delete(id: string): Promise<void>;
28
+ list(): Promise<MemoryUnit[]>;
29
+ }
30
+ export interface MemoryUnitStoreOptions {
31
+ validator?: JarenValidator<true>;
32
+ }
33
+ export declare function createMemoryUnitStore(options?: MemoryUnitStoreOptions): MemoryStore;
package/src/store.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The memory store seam and its default in-memory implementation.
3
+ *
4
+ * The contract is four async methods — `get`, `put`, `delete`, `list` —
5
+ * the same injection shape as the @jarenjs/ai ledger's storage seam, one
6
+ * level up: this store holds validated memory UNITS, not raw strings.
7
+ * A host backs it with SQLite, OPFS, a graph database, whatever; the
8
+ * policies in this package only ever see the contract.
9
+ *
10
+ * Every `put` is validated against `MEMORY_UNIT_SCHEMA`. The rule is the
11
+ * ledger's rule: a malformed write is rejected at the boundary, because
12
+ * a store that accepts junk makes every later policy pass unsound.
13
+ * Records are JSON-cloned on the way in and out, so no caller holds a
14
+ * live reference into the store — mutation happens through `put`, or
15
+ * not at all.
16
+ *
17
+ * TS note: `JarenValidator<true>` (inferred from `collectErrors: true`)
18
+ * makes `compile()` return a collector whose outcome is a typed
19
+ * `{ valid, errors }` — the shape-reading dance the JS version did is
20
+ * carried by the published d.ts now.
21
+ */
22
+ import { JarenValidator } from '@jarenjs/validate';
23
+ import { callerError } from '@tangleai/core/errors';
24
+ import { MEMORY_UNIT_SCHEMA, MEMORY_RELATION_SCHEMA, } from '@tangleai/core/schemas/memory';
25
+ function clone(value) {
26
+ return value === undefined ? value : JSON.parse(JSON.stringify(value));
27
+ }
28
+ export function createMemoryUnitStore(options = {}) {
29
+ const validator = options.validator
30
+ ?? new JarenValidator({ skipErrors: false, collectErrors: true, unknownFormats: 'ignore' });
31
+ validator.addSchema(MEMORY_RELATION_SCHEMA);
32
+ const validate = validator.compile(MEMORY_UNIT_SCHEMA);
33
+ const units = new Map();
34
+ return {
35
+ async get(id) {
36
+ return clone(units.get(id));
37
+ },
38
+ async put(unit) {
39
+ const outcome = validate(unit);
40
+ if (outcome.valid !== true) {
41
+ throw callerError(`memory unit rejected by schema: ${JSON.stringify(outcome.errors?.[0] ?? null)}`);
42
+ }
43
+ units.set(unit.id, clone(unit));
44
+ },
45
+ async delete(id) {
46
+ units.delete(id);
47
+ },
48
+ async list() {
49
+ return [...units.values()].map((u) => clone(u));
50
+ },
51
+ };
52
+ }