dsh-memsys 0.1.0 → 0.2.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.
- package/README.md +34 -7
- package/client.js +137 -0
- package/index.mjs +152 -11
- package/package.json +9 -2
- package/scripts/build.mjs +2 -1
- package/scripts/import-legacy.mjs +12 -2
- package/scripts/live-check.mjs +1 -1
- package/src/context/compile.mjs +6 -5
- package/src/context/delivery.mjs +1 -1
- package/src/context/snapshots.mjs +10 -2
- package/src/dream/organize.mjs +18 -3
- package/src/import/legacy.mjs +21 -2
- package/src/import/pointers.mjs +62 -20
- package/src/import/rollback.mjs +90 -37
- package/src/read/get.mjs +57 -6
- package/src/runtime/inject-round.mjs +138 -0
- package/src/runtime/inject.mjs +12 -3
- package/src/runtime/modules.mjs +20 -0
- package/src/runtime/summary.mjs +176 -0
- package/src/runtime/tools.mjs +21 -4
- package/src/runtime/ui-config.mjs +176 -0
- package/test/fixtures/p3/c1-baseline.json +90 -0
- package/test/p3-c1-dream.test.mjs +1 -1
- package/test/p4-p4-delivery.test.mjs +7 -1
- package/test/p6-b1-merge.test.mjs +60 -0
- package/test/p6-b2-pointers.test.mjs +77 -0
- package/test/p6-c1c4.test.mjs +69 -0
- package/test/p6-c3-append.test.mjs +85 -0
- package/test/p6-inject-round.test.mjs +76 -0
- package/test/p6-l6-rollback.test.mjs +38 -0
- package/test/p6-modules.test.mjs +77 -0
- package/test/p6-ui-summary.test.mjs +566 -0
package/src/import/pointers.mjs
CHANGED
|
@@ -5,6 +5,32 @@ import { withImmediate } from '../storage/open.mjs';
|
|
|
5
5
|
import { appendRelation } from '../storage/repositories.mjs';
|
|
6
6
|
import { WriteError } from '../write/authority.mjs';
|
|
7
7
|
|
|
8
|
+
export function verifyPointerSource({ row, match, observationText }) {
|
|
9
|
+
if (!match?.file || !match?.span) return { ok: false, reason: 'no_file_version_evidence' };
|
|
10
|
+
const start = Number(match.span.start);
|
|
11
|
+
const end = Number(match.span.end);
|
|
12
|
+
let bytes;
|
|
13
|
+
try {
|
|
14
|
+
bytes = fs.readFileSync(match.file);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return { ok: false, reason: 'file_unreadable', error: err.message };
|
|
17
|
+
}
|
|
18
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > bytes.length || start >= end) {
|
|
19
|
+
return { ok: false, reason: 'span_out_of_range' };
|
|
20
|
+
}
|
|
21
|
+
const slice = bytes.slice(start, end);
|
|
22
|
+
const spanHash = crypto.createHash('sha256').update(slice).digest('hex');
|
|
23
|
+
if (spanHash !== match.spanHash) return { ok: false, reason: 'span_hash_mismatch' };
|
|
24
|
+
const fileHash = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
25
|
+
if (match.fileHash && fileHash !== match.fileHash) return { ok: false, reason: 'file_hash_mismatch' };
|
|
26
|
+
if (match.location !== `${match.file}#bytes:${start}-${end}`) return { ok: false, reason: 'location_not_byte_binding' };
|
|
27
|
+
if (typeof observationText !== 'string' || slice.toString('utf8') !== observationText) {
|
|
28
|
+
return { ok: false, reason: 'observation_text_mismatch' };
|
|
29
|
+
}
|
|
30
|
+
if (match.versionHash !== row.source_version_hash) return { ok: false, reason: 'version_hash_mismatch' };
|
|
31
|
+
return { ok: true, spanHash, fileHash, observationText };
|
|
32
|
+
}
|
|
33
|
+
|
|
8
34
|
/** Match the imported source version, never "the latest path with this name". */
|
|
9
35
|
export function planPointers({ main, clock }, versionsFile) {
|
|
10
36
|
const parsed = JSON.parse(fs.readFileSync(versionsFile, 'utf8'));
|
|
@@ -35,28 +61,14 @@ export function planPointers({ main, clock }, versionsFile) {
|
|
|
35
61
|
skipped.push({ sourceObjectId: row.source_object_id, reason: 'no_version_match' });
|
|
36
62
|
continue;
|
|
37
63
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
const bytes = fs.readFileSync(match.file);
|
|
45
|
-
const start = Number(match.span.start);
|
|
46
|
-
const end = Number(match.span.end);
|
|
47
|
-
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > bytes.length || start >= end) {
|
|
48
|
-
skipped.push({ sourceObjectId: row.source_object_id, reason: 'span_out_of_range' });
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
spanHash = crypto.createHash('sha256').update(bytes.slice(start, end)).digest('hex');
|
|
52
|
-
} catch (err) {
|
|
53
|
-
skipped.push({ sourceObjectId: row.source_object_id, reason: 'file_unreadable', error: err.message });
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
if (spanHash !== match.spanHash) {
|
|
57
|
-
skipped.push({ sourceObjectId: row.source_object_id, reason: 'span_hash_mismatch' });
|
|
64
|
+
const observationId = targets[0];
|
|
65
|
+
const observation = observationId ? main.prepare('SELECT * FROM observations WHERE id = ?').get(observationId) : null;
|
|
66
|
+
const observed = verifyPointerSource({ row, match, observationText: observation?.text });
|
|
67
|
+
if (!observed.ok) {
|
|
68
|
+
skipped.push({ sourceObjectId: row.source_object_id, reason: observed.reason, error: observed.error });
|
|
58
69
|
continue;
|
|
59
70
|
}
|
|
71
|
+
const spanHash = observed.spanHash;
|
|
60
72
|
const pointer = main.prepare('SELECT * FROM pointers WHERE memory_id = ?').get(memoryId);
|
|
61
73
|
if (pointer?.status === 'exact' && pointer.location === match.location) {
|
|
62
74
|
skipped.push({ sourceObjectId: row.source_object_id, reason: 'already_exact' });
|
|
@@ -73,6 +85,8 @@ export function planPointers({ main, clock }, versionsFile) {
|
|
|
73
85
|
file: match.file,
|
|
74
86
|
span: match.span,
|
|
75
87
|
spanHash,
|
|
88
|
+
sourceFileHash: observed.fileHash,
|
|
89
|
+
observationHash: payloadHash(observed.observationText),
|
|
76
90
|
revision,
|
|
77
91
|
};
|
|
78
92
|
items.push({
|
|
@@ -91,8 +105,36 @@ export function planPointers({ main, clock }, versionsFile) {
|
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
export function applyPointers({ main, clock }, plan) {
|
|
108
|
+
const recomputed = payloadHash({ policy: 'legacy-v1', items: plan.items, skipped: plan.skipped });
|
|
109
|
+
if (recomputed !== plan.planHash) throw new WriteError('plan_hash_mismatch');
|
|
94
110
|
const applied = [];
|
|
95
111
|
for (const item of plan.items) {
|
|
112
|
+
let bytes;
|
|
113
|
+
try {
|
|
114
|
+
bytes = fs.readFileSync(item.payload.file);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
throw new WriteError('source_unreadable', { memoryId: item.memoryId, error: err.message });
|
|
117
|
+
}
|
|
118
|
+
const start = Number(item.payload.span.start);
|
|
119
|
+
const end = Number(item.payload.span.end);
|
|
120
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > bytes.length || start >= end) {
|
|
121
|
+
throw new WriteError('source_span_changed', { memoryId: item.memoryId });
|
|
122
|
+
}
|
|
123
|
+
const spanHash = crypto.createHash('sha256').update(bytes.slice(start, end)).digest('hex');
|
|
124
|
+
const fileHash = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
125
|
+
const evidence = main
|
|
126
|
+
.prepare('SELECT observation_id FROM evidence WHERE memory_id = ? ORDER BY id LIMIT 1')
|
|
127
|
+
.get(item.memoryId);
|
|
128
|
+
const observation = evidence?.observation_id
|
|
129
|
+
? main.prepare('SELECT text FROM observations WHERE id = ?').get(evidence.observation_id)
|
|
130
|
+
: null;
|
|
131
|
+
if (
|
|
132
|
+
spanHash !== item.payload.spanHash ||
|
|
133
|
+
fileHash !== item.payload.sourceFileHash ||
|
|
134
|
+
payloadHash(observation?.text ?? '') !== item.payload.observationHash
|
|
135
|
+
) {
|
|
136
|
+
throw new WriteError('source_bytes_changed', { memoryId: item.memoryId });
|
|
137
|
+
}
|
|
96
138
|
const exists = main
|
|
97
139
|
.prepare(
|
|
98
140
|
"SELECT payload FROM relations WHERE type = 'pointer-change' AND json_extract(payload, '$.memoryId') = ? AND json_extract(payload, '$.basis') = 'pointer-backfill'",
|
package/src/import/rollback.mjs
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
+
import { canonicalJson } from '../identity.mjs';
|
|
2
|
+
|
|
1
3
|
const TABLES = ['observations', 'candidates', 'memories', 'relations', 'import_items', 'timeline', 'pointers'];
|
|
4
|
+
const ID_TABLES = {
|
|
5
|
+
observations: 'id',
|
|
6
|
+
candidates: 'id',
|
|
7
|
+
memories: 'id',
|
|
8
|
+
relations: 'id',
|
|
9
|
+
import_items: 'id',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function rowHash(row) {
|
|
13
|
+
return canonicalJson({ ...row });
|
|
14
|
+
}
|
|
2
15
|
|
|
3
16
|
export function compareBusinessCounts({ db, expected = {} }) {
|
|
4
17
|
const missing = [];
|
|
5
18
|
const counts = {};
|
|
6
19
|
for (const [table, minimum] of Object.entries(expected)) {
|
|
7
20
|
if (!TABLES.includes(table)) continue;
|
|
8
|
-
|
|
9
|
-
try {
|
|
10
|
-
actual = Number(db.prepare('SELECT COUNT(*) AS n FROM ' + table).get().n);
|
|
11
|
-
} catch {
|
|
12
|
-
actual = 0;
|
|
13
|
-
}
|
|
21
|
+
const actual = Number(db.prepare('SELECT COUNT(*) AS n FROM ' + table).get().n);
|
|
14
22
|
counts[table] = actual;
|
|
15
23
|
if (actual < Number(minimum)) missing.push(table);
|
|
16
24
|
}
|
|
@@ -20,60 +28,105 @@ export function compareBusinessCounts({ db, expected = {} }) {
|
|
|
20
28
|
export function snapshotBusinessCounts(db) {
|
|
21
29
|
const counts = {};
|
|
22
30
|
for (const table of TABLES) {
|
|
23
|
-
|
|
24
|
-
counts[table] = Number(db.prepare('SELECT COUNT(*) AS n FROM ' + table).get().n);
|
|
25
|
-
} catch {
|
|
26
|
-
counts[table] = 0;
|
|
27
|
-
}
|
|
31
|
+
counts[table] = Number(db.prepare('SELECT COUNT(*) AS n FROM ' + table).get().n);
|
|
28
32
|
}
|
|
29
33
|
return counts;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
const ID_TABLES = {
|
|
33
|
-
observations: 'id',
|
|
34
|
-
candidates: 'id',
|
|
35
|
-
memories: 'id',
|
|
36
|
-
relations: 'id',
|
|
37
|
-
import_items: 'item_id',
|
|
38
|
-
};
|
|
39
|
-
|
|
40
36
|
export function snapshotBusinessRows(db) {
|
|
41
37
|
const ids = {};
|
|
38
|
+
const content = {};
|
|
42
39
|
for (const [table, key] of Object.entries(ID_TABLES)) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
const rows = db.prepare('SELECT * FROM ' + table + ' ORDER BY ' + key).all();
|
|
41
|
+
ids[table] = rows.map((row) => row[key]);
|
|
42
|
+
content[table] = Object.fromEntries(rows.map((row) => [row[key], rowHash(row)]));
|
|
43
|
+
}
|
|
44
|
+
const links = {};
|
|
45
|
+
for (const memoryId of ids.memories ?? []) {
|
|
46
|
+
const timeline = db.prepare('SELECT * FROM timeline WHERE memory_id = ?').get(memoryId) ?? null;
|
|
47
|
+
const pointer = db.prepare('SELECT * FROM pointers WHERE memory_id = ?').get(memoryId) ?? null;
|
|
48
|
+
const evidence = db.prepare('SELECT id FROM evidence WHERE memory_id = ? ORDER BY id').all(memoryId).map((row) => row.id);
|
|
49
|
+
links[memoryId] = {
|
|
50
|
+
timeline: timeline ? rowHash(timeline) : null,
|
|
51
|
+
pointer: pointer ? rowHash(pointer) : null,
|
|
52
|
+
evidence,
|
|
53
|
+
};
|
|
48
54
|
}
|
|
49
|
-
return { counts: snapshotBusinessCounts(db), ids };
|
|
55
|
+
return { counts: snapshotBusinessCounts(db), ids, content, links };
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
export function compareBusinessRows({ db, baseline = {} }) {
|
|
53
59
|
const missing = [];
|
|
54
60
|
const ids = baseline.ids ?? {};
|
|
61
|
+
const content = baseline.content ?? {};
|
|
55
62
|
for (const [table, key] of Object.entries(ID_TABLES)) {
|
|
56
63
|
for (const id of ids[table] ?? []) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
const row = db.prepare('SELECT * FROM ' + table + ' WHERE ' + key + ' = ?').get(id);
|
|
65
|
+
if (!row) {
|
|
66
|
+
missing.push(table + ':' + id);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const expectedHash = content[table]?.[id];
|
|
70
|
+
if (expectedHash && rowHash(row) !== expectedHash) {
|
|
71
|
+
missing.push(table + ':content:' + id);
|
|
62
72
|
}
|
|
63
|
-
if (!row) missing.push(table + ':' + id);
|
|
64
73
|
}
|
|
65
74
|
}
|
|
66
75
|
for (const memoryId of ids.memories ?? []) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
} catch {
|
|
76
|
+
const expected = baseline.links?.[memoryId];
|
|
77
|
+
const timeline = db.prepare('SELECT * FROM timeline WHERE memory_id = ?').get(memoryId) ?? null;
|
|
78
|
+
const pointer = db.prepare('SELECT * FROM pointers WHERE memory_id = ?').get(memoryId) ?? null;
|
|
79
|
+
const evidence = db.prepare('SELECT id FROM evidence WHERE memory_id = ? ORDER BY id').all(memoryId).map((row) => row.id);
|
|
80
|
+
if (!timeline || !pointer || evidence.length === 0) {
|
|
73
81
|
missing.push('memory_links:' + memoryId);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (expected) {
|
|
85
|
+
if ((timeline ? rowHash(timeline) : null) !== expected.timeline) missing.push('timeline:content:' + memoryId);
|
|
86
|
+
if ((pointer ? rowHash(pointer) : null) !== expected.pointer) missing.push('pointer:content:' + memoryId);
|
|
87
|
+
if (canonicalJson(evidence) !== canonicalJson(expected.evidence)) missing.push('evidence:content:' + memoryId);
|
|
74
88
|
}
|
|
75
89
|
}
|
|
76
90
|
const counts = compareBusinessCounts({ db, expected: baseline.counts ?? {} });
|
|
77
91
|
for (const key of counts.missing) missing.push('count:' + key);
|
|
78
92
|
return { ok: missing.length === 0, missing, counts: counts.counts };
|
|
79
93
|
}
|
|
94
|
+
|
|
95
|
+
const FREEZE_TABLES = {
|
|
96
|
+
main: ['observations', 'candidates', 'memories', 'relations', 'timeline', 'pointers', 'evidence', 'import_items', 'graph_nodes', 'graph_edges'],
|
|
97
|
+
control: ['operations', 'object_streams', 'context_snapshots', 'snapshot_groups', 'snapshot_items', 'operation_attempts'],
|
|
98
|
+
dream: ['dreams', 'propositions', 'relations', 'confirmation_snapshots'],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function tableFingerprint(db, table) {
|
|
102
|
+
const rows = db.prepare('SELECT * FROM ' + table + ' ORDER BY rowid').all();
|
|
103
|
+
return { count: rows.length, hash: canonicalJson(rows) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function snapshotDatabaseFreeze({ main, control, dream }) {
|
|
107
|
+
const freeze = { versions: {}, tables: {} };
|
|
108
|
+
for (const [label, db] of Object.entries({ main, control, dream })) {
|
|
109
|
+
if (!db) continue;
|
|
110
|
+
freeze.versions[label] = Number(db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations').get().version);
|
|
111
|
+
freeze.tables[label] = {};
|
|
112
|
+
for (const table of FREEZE_TABLES[label] ?? []) {
|
|
113
|
+
freeze.tables[label][table] = tableFingerprint(db, table);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return freeze;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function compareDatabaseFreeze({ main, control, dream }, freeze = {}) {
|
|
120
|
+
const missing = [];
|
|
121
|
+
for (const [label, db] of Object.entries({ main, control, dream })) {
|
|
122
|
+
if (!db || !freeze.versions?.[label]) continue;
|
|
123
|
+
const version = Number(db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations').get().version);
|
|
124
|
+
if (version !== Number(freeze.versions[label])) missing.push(label + ':version');
|
|
125
|
+
for (const [table, expected] of Object.entries(freeze.tables?.[label] ?? {})) {
|
|
126
|
+
const actual = tableFingerprint(db, table);
|
|
127
|
+
if (actual.count !== expected.count) missing.push(label + ':' + table + ':count');
|
|
128
|
+
else if (actual.hash !== expected.hash) missing.push(label + ':' + table + ':content');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { ok: missing.length === 0, missing };
|
|
132
|
+
}
|
package/src/read/get.mjs
CHANGED
|
@@ -156,7 +156,7 @@ export function getMemories({
|
|
|
156
156
|
return { status: 'error', code: 'validation_error', message: 'invalid_view', clock: reading };
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
let state = { ids, index: 0, offset: 0, versions: null };
|
|
159
|
+
let state = { ids, index: 0, offset: 0, versions: null, evIndex: null, evOffset: 0 };
|
|
160
160
|
if (cursor !== null) {
|
|
161
161
|
const decoded = decodeGetCursor(cursor);
|
|
162
162
|
if (!decoded || decoded.ids.join(',') !== ids.join(',')) {
|
|
@@ -166,7 +166,7 @@ export function getMemories({
|
|
|
166
166
|
if (JSON.stringify(decoded.versions ?? {}) !== JSON.stringify(currentVersions)) {
|
|
167
167
|
return { status: 'error', code: 'stale_cursor', clock: reading };
|
|
168
168
|
}
|
|
169
|
-
state = { ids: decoded.ids, index: decoded.index, offset: decoded.offset, versions: decoded.versions };
|
|
169
|
+
state = { ids: decoded.ids, index: decoded.index, offset: decoded.offset, versions: decoded.versions, evIndex: decoded.evIndex ?? null, evOffset: decoded.evOffset ?? 0 };
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
const budget = clampChars(maxChars);
|
|
@@ -252,19 +252,70 @@ export function getMemories({
|
|
|
252
252
|
history: view === 'history',
|
|
253
253
|
});
|
|
254
254
|
const text = formatted.text ?? '';
|
|
255
|
+
if (state.evIndex !== null) {
|
|
256
|
+
let left = budget - used;
|
|
257
|
+
const fragments = [];
|
|
258
|
+
let nextEv = null;
|
|
259
|
+
for (let e = state.evIndex; e < formatted.evidence.length; e += 1) {
|
|
260
|
+
const entry = formatted.evidence[e];
|
|
261
|
+
if (typeof entry.observation_text !== 'string' || entry.observation_text.length === 0) continue;
|
|
262
|
+
const chars = Array.from(entry.observation_text);
|
|
263
|
+
const start = e === state.evIndex ? state.evOffset : 0;
|
|
264
|
+
const take = Math.max(0, Math.min(chars.length - start, left));
|
|
265
|
+
if (take > 0) fragments.push({ ...entry, observation_text: chars.slice(start, start + take).join(''), observation_text_truncated: start + take < chars.length });
|
|
266
|
+
left -= take;
|
|
267
|
+
if (start + take < chars.length) {
|
|
268
|
+
nextEv = { evIndex: e, evOffset: start + take };
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
items.push({ ...formatted, evidence: fragments });
|
|
273
|
+
used = budget - left;
|
|
274
|
+
if (nextEv === null) {
|
|
275
|
+
index += 1;
|
|
276
|
+
offset = 0;
|
|
277
|
+
state.evIndex = null;
|
|
278
|
+
state.evOffset = 0;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const nextCursor = encodeGetCursor({
|
|
282
|
+
ids, index, offset: 0, evIndex: nextEv.evIndex, evOffset: nextEv.evOffset,
|
|
283
|
+
versions: state.versions ?? versionMapOf(main, ids),
|
|
284
|
+
});
|
|
285
|
+
return { status: 'ok', items, missing_ids: missing, snapshot_revision: currentRevision(main), next_cursor: nextCursor, truncated: true, clock: reading };
|
|
286
|
+
}
|
|
255
287
|
if (offset === 0 && used + codePoints(text) <= budget) {
|
|
256
288
|
if (includeEvidence && Array.isArray(formatted.evidence)) {
|
|
257
289
|
let left = budget - used - codePoints(text);
|
|
258
|
-
|
|
259
|
-
|
|
290
|
+
let nextEv = null;
|
|
291
|
+
const kept = [];
|
|
292
|
+
for (let e = 0; e < formatted.evidence.length; e += 1) {
|
|
293
|
+
const entry = formatted.evidence[e];
|
|
294
|
+
if (typeof entry.observation_text !== 'string' || entry.observation_text.length === 0) {
|
|
295
|
+
kept.push(entry);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
260
298
|
const chars = Array.from(entry.observation_text);
|
|
261
299
|
if (chars.length <= left) {
|
|
262
300
|
left -= chars.length;
|
|
301
|
+
kept.push(entry);
|
|
263
302
|
continue;
|
|
264
303
|
}
|
|
265
|
-
|
|
266
|
-
entry.observation_text_truncated
|
|
304
|
+
const take = Math.max(0, left);
|
|
305
|
+
kept.push({ ...entry, observation_text: chars.slice(0, take).join(''), observation_text_truncated: true });
|
|
306
|
+
nextEv = { evIndex: e, evOffset: take };
|
|
267
307
|
left = 0;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
formatted.evidence = kept;
|
|
311
|
+
if (nextEv !== null) {
|
|
312
|
+
items.push(formatted);
|
|
313
|
+
used = budget;
|
|
314
|
+
const nextCursor = encodeGetCursor({
|
|
315
|
+
ids, index, offset: 0, evIndex: nextEv.evIndex, evOffset: nextEv.evOffset,
|
|
316
|
+
versions: state.versions ?? versionMapOf(main, ids),
|
|
317
|
+
});
|
|
318
|
+
return { status: 'ok', items, missing_ids: missing, snapshot_revision: currentRevision(main), next_cursor: nextCursor, truncated: true, clock: reading };
|
|
268
319
|
}
|
|
269
320
|
}
|
|
270
321
|
items.push(formatted);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { resolveConfig } from '../config.mjs';
|
|
2
|
+
import { openAll } from '../storage/open.mjs';
|
|
3
|
+
import { migrateAll } from '../storage/migrate.mjs';
|
|
4
|
+
import { systemClock } from '../clock.mjs';
|
|
5
|
+
import { withImmediate } from '../storage/open.mjs';
|
|
6
|
+
|
|
7
|
+
const TOOLS_ONLY_TEXT = '[memsys] 注入:tools-only(未取得宿主预算与回执,不作逐轮交付承诺)';
|
|
8
|
+
|
|
9
|
+
function sessionIdOf(turn) {
|
|
10
|
+
return turn?.session?.header?.id ?? turn?.sessionId ?? turn?.session?.id ?? null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runtime bridge between session turn events and the compile pipeline.
|
|
15
|
+
* Subscription is owned here (not the section), so a new turn refreshes text
|
|
16
|
+
* before the next prompt section read; no reply receipt means delivery=unknown.
|
|
17
|
+
*/
|
|
18
|
+
export function createRoundInjector({
|
|
19
|
+
ctx = null,
|
|
20
|
+
dataDir,
|
|
21
|
+
modules = null,
|
|
22
|
+
compileRound,
|
|
23
|
+
tokenizer = null,
|
|
24
|
+
H = null,
|
|
25
|
+
mode = 'work',
|
|
26
|
+
clock = systemClock,
|
|
27
|
+
logger = console,
|
|
28
|
+
handles = null,
|
|
29
|
+
} = {}) {
|
|
30
|
+
const ownedHandles = !handles;
|
|
31
|
+
const stores = handles ?? openAll(resolveConfig({ dataDir }));
|
|
32
|
+
if (ownedHandles) migrateAll(stores, { clock });
|
|
33
|
+
let state = {
|
|
34
|
+
text: '',
|
|
35
|
+
delivery: 'unknown',
|
|
36
|
+
delivery_level: 'empty',
|
|
37
|
+
manifest: null,
|
|
38
|
+
sessionId: null,
|
|
39
|
+
roundId: null,
|
|
40
|
+
refreshed_at: null,
|
|
41
|
+
};
|
|
42
|
+
let counter = 0;
|
|
43
|
+
let disposed = false;
|
|
44
|
+
|
|
45
|
+
const disabled = modules?.context === false;
|
|
46
|
+
|
|
47
|
+
function recordInjections(roundId, manifest) {
|
|
48
|
+
const ids = manifest?.eligible_ids ?? [];
|
|
49
|
+
if (!ids.length) return;
|
|
50
|
+
const at = clock.at().at;
|
|
51
|
+
withImmediate(stores.main, () => {
|
|
52
|
+
for (const memoryId of ids) {
|
|
53
|
+
stores.main
|
|
54
|
+
.prepare('INSERT OR IGNORE INTO injections (round_id, memory_id, slots, topic_hits, manifest_hash, receipt_id, at) VALUES (?, ?, ?, 0, ?, NULL, ?)')
|
|
55
|
+
.run(roundId, memoryId, JSON.stringify(['context']), manifest?.manifest_hash ?? null, at);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function refresh(turn = {}) {
|
|
61
|
+
if (disposed) return state;
|
|
62
|
+
if (disabled) {
|
|
63
|
+
state = { text: '', delivery: 'unknown', delivery_level: 'disabled', manifest: null, sessionId: sessionIdOf(turn), roundId: null, refreshed_at: clock.at().at };
|
|
64
|
+
return state;
|
|
65
|
+
}
|
|
66
|
+
const sessionId = sessionIdOf(turn) ?? state.sessionId;
|
|
67
|
+
counter += 1;
|
|
68
|
+
const roundId = turn?.id ?? turn?.roundId ?? `turn-${counter}`;
|
|
69
|
+
let result;
|
|
70
|
+
try {
|
|
71
|
+
result = compileRound({
|
|
72
|
+
main: stores.main,
|
|
73
|
+
control: stores.control,
|
|
74
|
+
clock,
|
|
75
|
+
sessionId: sessionId ?? 'unknown-session',
|
|
76
|
+
roundId,
|
|
77
|
+
mode,
|
|
78
|
+
tokenizer,
|
|
79
|
+
H,
|
|
80
|
+
modules,
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
logger?.warn?.('[dsh-memsys] compile failed: ' + err.message);
|
|
84
|
+
result = null;
|
|
85
|
+
}
|
|
86
|
+
if (!result || result.delivery_level === 'unavailable') {
|
|
87
|
+
state = {
|
|
88
|
+
text: TOOLS_ONLY_TEXT,
|
|
89
|
+
delivery: 'unknown',
|
|
90
|
+
delivery_level: 'unavailable',
|
|
91
|
+
manifest: result?.manifest ?? null,
|
|
92
|
+
sessionId,
|
|
93
|
+
roundId,
|
|
94
|
+
refreshed_at: clock.at().at,
|
|
95
|
+
};
|
|
96
|
+
return state;
|
|
97
|
+
}
|
|
98
|
+
const text = result.section?.text ?? result.sectionText ?? '';
|
|
99
|
+
state = {
|
|
100
|
+
text,
|
|
101
|
+
delivery: 'unknown',
|
|
102
|
+
delivery_level: result.delivery_level,
|
|
103
|
+
manifest: result.manifest ?? null,
|
|
104
|
+
sessionId,
|
|
105
|
+
roundId,
|
|
106
|
+
refreshed_at: clock.at().at,
|
|
107
|
+
};
|
|
108
|
+
recordInjections(roundId, state.manifest);
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function onSessionEvent(payload) {
|
|
113
|
+
const event = payload?.event ?? payload;
|
|
114
|
+
const type = event?.type ?? event?.name ?? event?.kind ?? null;
|
|
115
|
+
if (type !== 'turn/start') return;
|
|
116
|
+
refresh(payload?.turn ?? event?.turn ?? payload ?? {});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!disabled && typeof ctx?.on === 'function') {
|
|
120
|
+
ctx.on('session/event', onSessionEvent);
|
|
121
|
+
ctx.on('turn/start', (payload) => refresh(payload ?? {}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
refresh,
|
|
126
|
+
current: () => state,
|
|
127
|
+
dispose() {
|
|
128
|
+
disposed = true;
|
|
129
|
+
try {
|
|
130
|
+
if (typeof ctx?.off === 'function') {
|
|
131
|
+
ctx.off('session/event', onSessionEvent);
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// disposal must not throw
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/runtime/inject.mjs
CHANGED
|
@@ -7,8 +7,11 @@ export const SECTION_ORDER = 500;
|
|
|
7
7
|
* The only verified injection call shape (probe-p0):
|
|
8
8
|
* ctx.inject(['systemPrompt'], c => c.systemPrompt.section({name, order, text}))
|
|
9
9
|
* Static `export const inject` would leave the plugin pending forever.
|
|
10
|
+
*
|
|
11
|
+
* `dynamicText: true` registers the resolver itself so the host re-reads it per
|
|
12
|
+
* assembly; the default keeps the historical one-shot string.
|
|
10
13
|
*/
|
|
11
|
-
export function registerSection(ctx, { getText, diagnosticMarker = null } = {}) {
|
|
14
|
+
export function registerSection(ctx, { getText, diagnosticMarker = null, dynamicText = false } = {}) {
|
|
12
15
|
if (!ctx || typeof ctx.inject !== 'function') {
|
|
13
16
|
const err = new Error('invalid_ctx');
|
|
14
17
|
err.code = 'invalid_ctx';
|
|
@@ -20,7 +23,12 @@ export function registerSection(ctx, { getText, diagnosticMarker = null } = {})
|
|
|
20
23
|
err.code = 'system_prompt_unavailable';
|
|
21
24
|
throw err;
|
|
22
25
|
}
|
|
23
|
-
const
|
|
26
|
+
const resolve = () => (typeof getText === 'function' ? getText() : String(getText ?? ''));
|
|
27
|
+
// Dynamic text stays a function: the host re-evaluates it on every
|
|
28
|
+
// `systemPrompt.assemble()`, i.e. once per model step (verified on a probe
|
|
29
|
+
// profile: two assemblies produced probe-1 then probe-2). A static string
|
|
30
|
+
// would freeze the first round forever.
|
|
31
|
+
const text = dynamicText ? resolve : resolve();
|
|
24
32
|
c.systemPrompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text });
|
|
25
33
|
if (diagnosticMarker) {
|
|
26
34
|
fs.writeFileSync(
|
|
@@ -29,7 +37,8 @@ export function registerSection(ctx, { getText, diagnosticMarker = null } = {})
|
|
|
29
37
|
{
|
|
30
38
|
name: SECTION_NAME,
|
|
31
39
|
order: SECTION_ORDER,
|
|
32
|
-
text_length: String(
|
|
40
|
+
text_length: String(resolve()).length,
|
|
41
|
+
dynamic: dynamicText === true,
|
|
33
42
|
registered_at: new Date().toISOString(),
|
|
34
43
|
},
|
|
35
44
|
null,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core gate facade. The single merge/persistence source is ui-config.mjs;
|
|
3
|
+
* this file must never re-implement it.
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
DEFAULT_MODULES,
|
|
7
|
+
MODULE_KEYS,
|
|
8
|
+
moduleEnabled,
|
|
9
|
+
readUiConfig,
|
|
10
|
+
resolveModules,
|
|
11
|
+
resolveModulesDetail,
|
|
12
|
+
writeUiConfig,
|
|
13
|
+
} from './ui-config.mjs';
|
|
14
|
+
|
|
15
|
+
import { resolveModulesDetail } from './ui-config.mjs';
|
|
16
|
+
|
|
17
|
+
/** Resolve the effective gate set for one plugin instance. */
|
|
18
|
+
export function modulesFor({ dataDir = null, config = null, logger = console } = {}) {
|
|
19
|
+
return resolveModulesDetail({ dataDir, profileConfig: config, logger }).modules;
|
|
20
|
+
}
|