@tangleai/context 0.21.1

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.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The slot name for one archived round. Content-addressed: same bytes,
3
+ * same name, so a re-compaction overwrites rather than duplicates.
4
+ * @param {string} text - the round's serialized wire messages
5
+ * @returns {string}
6
+ */
7
+ export function roundSlotName(text: string): string;
8
+ /**
9
+ * The slot name for the index of one archived set. Content-addressed the
10
+ * same way, over the listing itself — so a request that dropped the same
11
+ * rounds names the same index.
12
+ * @param {string} text - the index listing
13
+ * @returns {string}
14
+ */
15
+ export function indexSlotName(text: string): string;
16
+ /**
17
+ * One address, written the one way it is ever written — the call a model
18
+ * would make to follow it. Everything that names a slot in prose goes
19
+ * through here, so `slotAddressesIn` has a single syntax to read and a
20
+ * reader following an address is never following a second convention.
21
+ * @param {string} name
22
+ * @returns {string}
23
+ */
24
+ export function slotRef(name: string): string;
25
+ /**
26
+ * The address as it appears in a synopsis line. Short on purpose: it is
27
+ * paid for out of the same character budget the rounds were cut to fit.
28
+ * @param {string} name
29
+ * @param {number} size - the archived round's size in characters
30
+ * @returns {string}
31
+ */
32
+ export function slotAddress(name: string, size: number): string;
33
+ /**
34
+ * Every slot address named in a piece of text, in order, without
35
+ * duplicates. The one reader of the address syntax — a probe, a test or
36
+ * a host auditing what a request can still reach uses this rather than
37
+ * writing the pattern a second time.
38
+ * @param {string} text
39
+ * @returns {string[]}
40
+ */
41
+ export function slotAddressesIn(text: string): string[];
42
+ /**
43
+ * The `recall` tool definition, bound to a ledger.
44
+ *
45
+ * Registered by `createAgent` when it is given a ledger, and exported so
46
+ * a host can add it to its own toolbox — the same tool either way, since
47
+ * a second implementation is exactly how the address a synopsis promises
48
+ * and the address a tool answers would come to differ.
49
+ *
50
+ * The result is deliberately plain `{ slot, size, content }`: the agent
51
+ * loop truncates every tool result to `maxToolResultChars` on the way
52
+ * into the transcript, so a slot bigger than that is cut there, once, by
53
+ * the same rule as any other oversized result.
54
+ *
55
+ * @param {{ readSlot: (name: string) => Promise<any> }} ledger
56
+ * @param {{ index?: string }} [options] - `index` is the address of the
57
+ * listing to point at when a name is unknown; it travels in the
58
+ * rejection because a model that mistyped an address needs the place
59
+ * the real ones are written down, not just a refusal.
60
+ * @returns {{ name: string, description: string, inputSchema: any,
61
+ * execute: (input: any) => Promise<any> }}
62
+ */
63
+ export function createRecallTool(ledger: {
64
+ readSlot: (name: string) => Promise<any>;
65
+ }, options?: {
66
+ index?: string;
67
+ }): {
68
+ name: string;
69
+ description: string;
70
+ inputSchema: any;
71
+ execute: (input: any) => Promise<any>;
72
+ };
73
+ /** The tool's name. A host that registers its own `recall` keeps it. */
74
+ export const RECALL_TOOL_NAME: "recall";
package/src/recall.js ADDED
@@ -0,0 +1,160 @@
1
+ //@ts-check
2
+ /**
3
+ * `recall` — the address scheme compaction writes, and the tool that
4
+ * reads it back.
5
+ *
6
+ * Compaction used to be a one-way door: a dropped tool round left the
7
+ * request as a 60-character excerpt and the rest of it was gone. The
8
+ * excerpt is now a *preview* of something that still exists, and this
9
+ * module owns both halves of that promise — the address a synopsis line
10
+ * carries, and the tool a model calls to follow it. They live together
11
+ * because they are one contract: a name written by `agent.js` and read
12
+ * by a model has to be produced and parsed in exactly one place, or the
13
+ * two drift and a request ends up naming an address nothing answers.
14
+ *
15
+ * Three decisions:
16
+ *
17
+ * - **The name is content-addressed.** A round's slot name is derived
18
+ * from the round's own bytes, so compacting the same history twice
19
+ * writes the same slot instead of a second copy — idempotence falls
20
+ * out of the naming rather than being bolted on with a counter. The
21
+ * character length rides beside the 32-bit fingerprint. These can
22
+ * collide, so compaction compares exact bytes and refuses a conflicting
23
+ * address before dropping anything from the transcript.
24
+ * - **Recall is a tool, not a mechanism.** Nothing re-expands a dropped
25
+ * round automatically. Automatic re-expansion is a guess about which
26
+ * round matters, and a wrong guess spends the budget it was trying to
27
+ * save; the model already knows what it is looking for. It also keeps
28
+ * the machinery inspectable — a recall appears in `steps` like any
29
+ * other call.
30
+ * - **It never throws.** An unknown address answers `{ error }` with the
31
+ * index address to try instead, exactly as every other tool in this
32
+ * package answers a content-level problem.
33
+ */
34
+
35
+ import { hashContent } from '@jarenjs/core/string';
36
+
37
+ /** The tool's name. A host that registers its own `recall` keeps it. */
38
+ export const RECALL_TOOL_NAME = 'recall';
39
+
40
+ /** Address prefixes: one archived round, and the index that lists them. */
41
+ const ROUND_PREFIX = 'r-';
42
+ const INDEX_PREFIX = 'rx-';
43
+
44
+ /**
45
+ * The slot name for one archived round. Content-addressed: same bytes,
46
+ * same name, so a re-compaction overwrites rather than duplicates.
47
+ * @param {string} text - the round's serialized wire messages
48
+ * @returns {string}
49
+ */
50
+ export function roundSlotName(text) {
51
+ return `${ROUND_PREFIX}${hashContent(text)}-${text.length}`;
52
+ }
53
+
54
+ /**
55
+ * The slot name for the index of one archived set. Content-addressed the
56
+ * same way, over the listing itself — so a request that dropped the same
57
+ * rounds names the same index.
58
+ * @param {string} text - the index listing
59
+ * @returns {string}
60
+ */
61
+ export function indexSlotName(text) {
62
+ return `${INDEX_PREFIX}${hashContent(text)}-${text.length}`;
63
+ }
64
+
65
+ /**
66
+ * One address, written the one way it is ever written — the call a model
67
+ * would make to follow it. Everything that names a slot in prose goes
68
+ * through here, so `slotAddressesIn` has a single syntax to read and a
69
+ * reader following an address is never following a second convention.
70
+ * @param {string} name
71
+ * @returns {string}
72
+ */
73
+ export function slotRef(name) {
74
+ return `recall("${name}")`;
75
+ }
76
+
77
+ /**
78
+ * The address as it appears in a synopsis line. Short on purpose: it is
79
+ * paid for out of the same character budget the rounds were cut to fit.
80
+ * @param {string} name
81
+ * @param {number} size - the archived round's size in characters
82
+ * @returns {string}
83
+ */
84
+ export function slotAddress(name, size) {
85
+ return `[${slotRef(name)} · ${size}B]`;
86
+ }
87
+
88
+ /**
89
+ * Every slot address named in a piece of text, in order, without
90
+ * duplicates. The one reader of the address syntax — a probe, a test or
91
+ * a host auditing what a request can still reach uses this rather than
92
+ * writing the pattern a second time.
93
+ * @param {string} text
94
+ * @returns {string[]}
95
+ */
96
+ export function slotAddressesIn(text) {
97
+ /** @type {string[]} */
98
+ const names = [];
99
+ for (const match of String(text ?? '').matchAll(/recall\("([^"\s]+)"\)/g)) {
100
+ if (!names.includes(match[1])) names.push(match[1]);
101
+ }
102
+ return names;
103
+ }
104
+
105
+ /**
106
+ * The `recall` tool definition, bound to a ledger.
107
+ *
108
+ * Registered by `createAgent` when it is given a ledger, and exported so
109
+ * a host can add it to its own toolbox — the same tool either way, since
110
+ * a second implementation is exactly how the address a synopsis promises
111
+ * and the address a tool answers would come to differ.
112
+ *
113
+ * The result is deliberately plain `{ slot, size, content }`: the agent
114
+ * loop truncates every tool result to `maxToolResultChars` on the way
115
+ * into the transcript, so a slot bigger than that is cut there, once, by
116
+ * the same rule as any other oversized result.
117
+ *
118
+ * @param {{ readSlot: (name: string) => Promise<any> }} ledger
119
+ * @param {{ index?: string }} [options] - `index` is the address of the
120
+ * listing to point at when a name is unknown; it travels in the
121
+ * rejection because a model that mistyped an address needs the place
122
+ * the real ones are written down, not just a refusal.
123
+ * @returns {{ name: string, description: string, inputSchema: any,
124
+ * execute: (input: any) => Promise<any> }}
125
+ */
126
+ export function createRecallTool(ledger, options = {}) {
127
+ return {
128
+ name: RECALL_TOOL_NAME,
129
+ description: 'Fetch an archived conversation round in full by its address. Earlier rounds'
130
+ + ' that did not fit the history budget were archived, not deleted: each synopsis line'
131
+ + ' carries its address as recall("name"), and the index address listed in the synopsis'
132
+ + ' header returns every address there is.',
133
+ inputSchema: {
134
+ type: 'object',
135
+ properties: {
136
+ slot: {
137
+ type: 'string',
138
+ minLength: 1,
139
+ description: 'The address from a synopsis line, e.g. the name inside recall("…").',
140
+ },
141
+ },
142
+ required: ['slot'],
143
+ additionalProperties: false,
144
+ },
145
+ execute: async ({ slot }) => {
146
+ const content = await ledger.readSlot(slot);
147
+ if (content?.status === 'evicted') return { error: `slot '${slot}' was evicted`, ...content };
148
+ if (content === undefined || content === null) {
149
+ return {
150
+ error: `unknown slot '${slot}'`,
151
+ ...(options.index === undefined ? {} : { index: options.index }),
152
+ hint: options.index === undefined
153
+ ? 'use an address exactly as it appears inside recall("…") in the synopsis'
154
+ : `${slotRef(options.index)} lists every address that exists`,
155
+ };
156
+ }
157
+ return { slot, size: String(content).length, content };
158
+ },
159
+ };
160
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Exact whole-map accounting. Braces belong to overhead; each member owns its
3
+ * leading comma. Archive metadata, contents, reports and tombstones stay visible.
4
+ * @param {Record<string, any>} records
5
+ */
6
+ export function ledgerFootprint(records: Record<string, any>): {
7
+ bytes: any;
8
+ overhead: number;
9
+ classes: {};
10
+ };
11
+ /** Lossless checkpoint: repeated note/evidence pairs share a dictionary entry. */
12
+ export function checkpointProgress(goal: any): {
13
+ version: number;
14
+ sources: any[];
15
+ records: any[];
16
+ };
17
+ /** Validate exact source coverage and evidence, including prior checkpoints. */
18
+ export function validateCheckpoint(checkpoint: any, goal: any): {
19
+ valid: boolean;
20
+ errors: any[];
21
+ };
22
+ /** Complete goal context. No excerpting or silent clipping of evidence. */
23
+ export function goalPrompt(goal: any): string;
24
+ export function jsonBytes(value: any): number;
@@ -0,0 +1,91 @@
1
+ //@ts-check
2
+ import { JarenValidator } from '@jarenjs/validate';
3
+ import { GOAL_SCHEMA } from './schemas/ledger.js';
4
+ import { checkOutcome } from '@jarenjs/core/check';
5
+ const checkpointCheck = new JarenValidator({ collectErrors: true, skipErrors: false, unknownFormats: 'ignore' }).compile(GOAL_SCHEMA.properties.checkpoint);
6
+
7
+ /** Serialized UTF-8 bytes, including JSON escaping and punctuation. */
8
+ export const jsonBytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
9
+
10
+ /**
11
+ * Exact whole-map accounting. Braces belong to overhead; each member owns its
12
+ * leading comma. Archive metadata, contents, reports and tombstones stay visible.
13
+ * @param {Record<string, any>} records
14
+ */
15
+ export function ledgerFootprint(records) {
16
+ const classes = {};
17
+ Object.keys(records).sort().forEach((key, index) => {
18
+ let kind = 'other';
19
+ if (key.startsWith('ai/snap/')) kind = 'snapshots';
20
+ else if (key.startsWith('ai/counters/')) kind = 'counters';
21
+ else if (key.startsWith('ai/state/memory/')) kind = 'memories';
22
+ else if (key.startsWith('ai/state/skill/')) kind = 'skills';
23
+ else if (key.startsWith('ai/state/goal/')) kind = 'goals';
24
+ else if (key.startsWith('ai/state/evicted/') || key.startsWith('ai/state/retention/')) kind = 'retention';
25
+ else if (key.startsWith('ai/state/slot/')) kind = records[key]?.kind?.startsWith('agent-round') ? 'archives' : 'slots';
26
+ else if (key.startsWith('ai/state/slot-content/')) {
27
+ const name = key.slice('ai/state/slot-content/'.length);
28
+ kind = records[`ai/state/slot/${name}`]?.kind?.startsWith('agent-round') ? 'archives' : 'slots';
29
+ }
30
+ const entry = classes[kind] ??= { bytes: 0, items: 0 };
31
+ entry.bytes += jsonBytes(key) + 1 + jsonBytes(records[key]) + (index ? 1 : 0);
32
+ entry.items++;
33
+ });
34
+ return { bytes: 2 + Object.values(classes).reduce((n, entry) => n + entry.bytes, 0), overhead: 2, classes };
35
+ }
36
+
37
+ /** Lossless checkpoint: repeated note/evidence pairs share a dictionary entry. */
38
+ export function checkpointProgress(goal) {
39
+ const records = [], sources = [];
40
+ const add = (source, record) => {
41
+ if (typeof source?.id !== 'string' || source.id === '' || !record) throw new TypeError('checkpoint needs identified source entries');
42
+ let index = records.findIndex((held) => held.note === record.note && held.evidence === record.evidence);
43
+ if (index < 0) { index = records.length; records.push({ note: record.note, evidence: record.evidence }); }
44
+ sources.push({ id: source.id, at: source.at, record: index });
45
+ };
46
+ for (const source of goal.checkpoint?.sources ?? []) add(source, goal.checkpoint.records[source.record]);
47
+ for (const entry of goal.progress) add(entry, entry);
48
+ return { version: 1, sources, records };
49
+ }
50
+
51
+ /** Validate exact source coverage and evidence, including prior checkpoints. */
52
+ export function validateCheckpoint(checkpoint, goal) {
53
+ const shape = checkOutcome(checkpointCheck(checkpoint));
54
+ if (!shape.valid) return { valid: false, errors: shape.errors.map((error) => ({ ...error,
55
+ code: 'GOAL_CHECKPOINT', docPath: `/checkpoint${error.instancePath ?? ''}` })) };
56
+ let expected;
57
+ try { expected = checkpointProgress(goal); }
58
+ catch { return { valid: false, errors: [{ code: 'GOAL_CHECKPOINT', docPath: '/goal', message: 'invalid checkpoint source goal' }] }; }
59
+ const errors = [];
60
+ const add = (path, message) => errors.push({ code: 'GOAL_CHECKPOINT', docPath: path, instancePath: path, message });
61
+ if (checkpoint?.version !== 1 || !Array.isArray(checkpoint?.sources) || !Array.isArray(checkpoint?.records))
62
+ add('/checkpoint', 'expected a versioned checkpoint with sources and records');
63
+ else {
64
+ const sources = new Map(expected.sources.map((source) => [source.id, source]));
65
+ const seen = new Set();
66
+ checkpoint.sources.forEach((source, index) => {
67
+ const held = sources.get(source?.id), record = checkpoint.records[source?.record];
68
+ if (!held || seen.has(source.id) || source.at !== held.at || !Number.isSafeInteger(source.record)
69
+ || (record?.note !== expected.records[held.record].note || record?.evidence !== expected.records[held.record].evidence))
70
+ add(`/checkpoint/sources/${index}`, 'source is invented, duplicated or changes its note/evidence');
71
+ seen.add(source?.id);
72
+ });
73
+ if (seen.size !== sources.size || [...sources.keys()].some((id) => !seen.has(id)))
74
+ add('/checkpoint/sources', 'every retired source id must remain covered');
75
+ if (checkpoint.records.some((_, index) => !checkpoint.sources.some((source) => source.record === index)))
76
+ add('/checkpoint/records', 'unreferenced checkpoint record');
77
+ }
78
+ return { valid: errors.length === 0, errors };
79
+ }
80
+
81
+ /** Complete goal context. No excerpting or silent clipping of evidence. */
82
+ export function goalPrompt(goal) {
83
+ const progress = Array.isArray(goal.progress) ? goal.progress : [];
84
+ const lines = ['## Your objective (persistent, across sessions)', goal.objective];
85
+ if (goal.checkpoint) lines.push('', 'Validated checkpoint — this work is DONE, do not repeat it:', JSON.stringify(goal.checkpoint));
86
+ if (progress.length) {
87
+ lines.push('', `Progress recorded so far (${progress.length} entr${progress.length === 1 ? 'y' : 'ies'}) — this work is DONE, do not repeat it:`);
88
+ for (const entry of progress) lines.push(`- [${entry.at}] ${entry.note} (evidence: ${entry.evidence})`);
89
+ }
90
+ return lines.join('\n');
91
+ }
@@ -0,0 +1,54 @@
1
+ /** An admitted artifact describes identity and location; validation never fetches it. */
2
+ export const ARTIFACT_SCHEMA: {
3
+ type: "object";
4
+ properties: Record<string, object>;
5
+ required: string[];
6
+ additionalProperties: false;
7
+ };
8
+ /** Evidence selects content from one admitted artifact. */
9
+ export const EVIDENCE_SCHEMA: {
10
+ type: "object";
11
+ properties: Record<string, object>;
12
+ required: string[];
13
+ additionalProperties: false;
14
+ };
15
+ /** Status is an explicit author assertion, not an entailment or authority score. */
16
+ export const CLAIM_SCHEMA: {
17
+ type: "object";
18
+ properties: Record<string, object>;
19
+ required: string[];
20
+ additionalProperties: false;
21
+ };
22
+ export namespace CLAIM_EVIDENCE_SCHEMA {
23
+ let type: "object";
24
+ let properties: Record<string, object>;
25
+ let required: string[];
26
+ let additionalProperties: false;
27
+ }
28
+ export type ArtifactRecord = {
29
+ id: string;
30
+ kind: string;
31
+ locator?: string;
32
+ digest?: string;
33
+ metadata?: Record<string, any>;
34
+ };
35
+ export type EvidenceRecord = {
36
+ id: string;
37
+ artifact: string;
38
+ selector?: string;
39
+ quote?: string;
40
+ };
41
+ export type ClaimRecord = {
42
+ id: string;
43
+ text: string;
44
+ critical: boolean;
45
+ status: "supported" | "unresolved";
46
+ evidence: string[];
47
+ };
48
+ export type ClaimEvidenceEnvelope = {
49
+ version: 1;
50
+ artifacts: ArtifactRecord[];
51
+ evidence: EvidenceRecord[];
52
+ claims: ClaimRecord[];
53
+ visibleEvidence: string[];
54
+ };
@@ -0,0 +1,32 @@
1
+ //@ts-check
2
+ const ID = { type: 'string', minLength: 1 };
3
+ /**
4
+ * @param {Record<string, object>} properties
5
+ * @param {string[]} required
6
+ * @returns {{ type: 'object', properties: Record<string, object>, required: string[], additionalProperties: false }}
7
+ */
8
+ const record = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
9
+
10
+ /** An admitted artifact describes identity and location; validation never fetches it. */
11
+ export const ARTIFACT_SCHEMA = record({ id: ID, kind: ID, locator: ID, digest: ID,
12
+ metadata: { type: 'object' } }, ['id', 'kind']);
13
+ /** Evidence selects content from one admitted artifact. */
14
+ export const EVIDENCE_SCHEMA = record({ id: ID, artifact: ID, selector: ID, quote: ID }, ['id', 'artifact']);
15
+ /** Status is an explicit author assertion, not an entailment or authority score. */
16
+ export const CLAIM_SCHEMA = record({ id: ID, text: ID, critical: { type: 'boolean' },
17
+ status: { enum: ['supported', 'unresolved'] },
18
+ evidence: { type: 'array', items: ID, uniqueItems: true } }, ['id', 'text', 'critical', 'status', 'evidence']);
19
+ /** Versioned referential envelope; visible ids are the evidence admitted to this view. */
20
+ export const CLAIM_EVIDENCE_SCHEMA = {
21
+ ...record({ version: { const: 1 }, artifacts: { type: 'array', items: ARTIFACT_SCHEMA },
22
+ evidence: { type: 'array', items: EVIDENCE_SCHEMA }, claims: { type: 'array', items: CLAIM_SCHEMA },
23
+ visibleEvidence: { type: 'array', items: ID, uniqueItems: true } },
24
+ ['version', 'artifacts', 'evidence', 'claims', 'visibleEvidence']),
25
+ };
26
+
27
+ /**
28
+ * @typedef {{ id: string, kind: string, locator?: string, digest?: string, metadata?: Record<string, any> }} ArtifactRecord
29
+ * @typedef {{ id: string, artifact: string, selector?: string, quote?: string }} EvidenceRecord
30
+ * @typedef {{ id: string, text: string, critical: boolean, status: 'supported'|'unresolved', evidence: string[] }} ClaimRecord
31
+ * @typedef {{ version: 1, artifacts: ArtifactRecord[], evidence: EvidenceRecord[], claims: ClaimRecord[], visibleEvidence: string[] }} ClaimEvidenceEnvelope
32
+ */