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.
@@ -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
- if (!match.file || !match.span || !match.spanHash) {
39
- skipped.push({ sourceObjectId: row.source_object_id, reason: 'no_file_version_evidence' });
40
- continue;
41
- }
42
- let spanHash = null;
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'",
@@ -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
- let actual = 0;
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
- try {
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
- try {
44
- ids[table] = db.prepare('SELECT ' + key + ' AS id FROM ' + table + ' ORDER BY 1').all().map((row) => row.id);
45
- } catch {
46
- ids[table] = [];
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
- let row = null;
58
- try {
59
- row = db.prepare('SELECT ' + key + ' AS id FROM ' + table + ' WHERE ' + key + ' = ?').get(id);
60
- } catch {
61
- row = null;
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
- try {
68
- const timeline = db.prepare('SELECT 1 AS ok FROM timeline WHERE memory_id = ?').get(memoryId);
69
- const pointer = db.prepare('SELECT 1 AS ok FROM pointers WHERE memory_id = ?').get(memoryId);
70
- const evidence = db.prepare('SELECT 1 AS ok FROM evidence WHERE memory_id = ? LIMIT 1').get(memoryId);
71
- if (!timeline || !pointer || !evidence) missing.push('memory_links:' + memoryId);
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
- for (const entry of formatted.evidence) {
259
- if (typeof entry.observation_text !== 'string' || entry.observation_text.length === 0) continue;
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
- entry.observation_text = chars.slice(0, Math.max(0, left)).join('');
266
- entry.observation_text_truncated = true;
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
+ }
@@ -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 text = typeof getText === 'function' ? getText() : String(getText ?? '');
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(text).length,
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
+ }