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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host side of the optional Web UI (sidebar panel + settings section).
|
|
3
|
+
*
|
|
4
|
+
* Read-only over SQLite, plus the module switches read through `ui-config.mjs`
|
|
5
|
+
* (the same source the host gates use). Both routes enforce loopback +
|
|
6
|
+
* same-origin before touching data; nothing here is model-facing.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_MODULES,
|
|
10
|
+
MODULE_KEYS,
|
|
11
|
+
readUiConfig,
|
|
12
|
+
resolveModulesDetail,
|
|
13
|
+
writeUiConfig,
|
|
14
|
+
} from './ui-config.mjs';
|
|
15
|
+
|
|
16
|
+
const RECENT_LIMIT = 5;
|
|
17
|
+
const TEXT_LIMIT = 80;
|
|
18
|
+
const BODY_LIMIT = 4096;
|
|
19
|
+
|
|
20
|
+
const truncate = (text) => Array.from(String(text ?? '')).slice(0, TEXT_LIMIT).join('');
|
|
21
|
+
|
|
22
|
+
/** Three counts, the five latest memories, and the effective module map. */
|
|
23
|
+
export function buildSummary(
|
|
24
|
+
main,
|
|
25
|
+
{ dataDir = null, modules = DEFAULT_MODULES, modulesSource = 'default', autoInject = undefined } = {},
|
|
26
|
+
) {
|
|
27
|
+
const db = typeof main === 'function' ? main() : main;
|
|
28
|
+
const one = (sql, ...params) => Number(db.prepare(sql).get(...params)?.n ?? 0);
|
|
29
|
+
const recent = db
|
|
30
|
+
.prepare('SELECT id, text FROM memories ORDER BY recorded_at DESC, id DESC LIMIT ?')
|
|
31
|
+
.all(RECENT_LIMIT)
|
|
32
|
+
.map((row) => ({ id: row.id, text: truncate(row.text) }));
|
|
33
|
+
const effective = {};
|
|
34
|
+
for (const key of MODULE_KEYS) effective[key] = modules?.[key] !== false;
|
|
35
|
+
return {
|
|
36
|
+
memories: one('SELECT COUNT(*) AS n FROM memories'),
|
|
37
|
+
candidates: one("SELECT COUNT(*) AS n FROM candidates WHERE status IN ('pending', 'conflicted')"),
|
|
38
|
+
pending_due: one("SELECT COUNT(*) AS n FROM memories WHERE due_state = 'pending-due'"),
|
|
39
|
+
recent,
|
|
40
|
+
data_dir: dataDir,
|
|
41
|
+
auto_inject: autoInject === undefined ? effective.context : autoInject === true,
|
|
42
|
+
auto_inject_source: modulesSource,
|
|
43
|
+
modules: effective,
|
|
44
|
+
modules_source: modulesSource,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isLoopback(req) {
|
|
49
|
+
const address = req?.socket?.remoteAddress ?? '';
|
|
50
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sameOrigin(req) {
|
|
54
|
+
const origin = req?.headers?.origin;
|
|
55
|
+
const host = req?.headers?.host;
|
|
56
|
+
if (!origin) return true;
|
|
57
|
+
try {
|
|
58
|
+
return new URL(origin).host === host;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One handler for GET summary and POST config.
|
|
66
|
+
*
|
|
67
|
+
* `main` may be a thunk so a profile that never opened the database still gets
|
|
68
|
+
* a structured 503 instead of a thrown route error.
|
|
69
|
+
*/
|
|
70
|
+
export function createSummaryHandler({
|
|
71
|
+
main,
|
|
72
|
+
dataDir = null,
|
|
73
|
+
getConfig = () => ({ modules: DEFAULT_MODULES, source: 'default' }),
|
|
74
|
+
onConfig = null,
|
|
75
|
+
} = {}) {
|
|
76
|
+
const send = (res, status, payload) => {
|
|
77
|
+
res.statusCode = status;
|
|
78
|
+
res.setHeader('content-type', 'application/json');
|
|
79
|
+
res.setHeader('cache-control', 'no-store');
|
|
80
|
+
res.end(JSON.stringify(payload));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const snapshot = () => {
|
|
84
|
+
const current = getConfig() ?? {};
|
|
85
|
+
const modules = current.modules ?? DEFAULT_MODULES;
|
|
86
|
+
return {
|
|
87
|
+
modules,
|
|
88
|
+
source: current.source ?? 'default',
|
|
89
|
+
auto_inject: modules?.context !== false,
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (req, res) => {
|
|
94
|
+
if (!isLoopback(req) || !sameOrigin(req)) {
|
|
95
|
+
send(res, 403, { error: 'forbidden' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (req.method === 'POST') {
|
|
99
|
+
const type = String(req.headers?.['content-type'] ?? '').toLowerCase();
|
|
100
|
+
if (!type.includes('application/json')) {
|
|
101
|
+
send(res, 415, { error: 'json_required' });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let body = '';
|
|
105
|
+
let tooLarge = false;
|
|
106
|
+
req.on?.('data', (chunk) => {
|
|
107
|
+
if (tooLarge) return;
|
|
108
|
+
body += chunk;
|
|
109
|
+
if (body.length > BODY_LIMIT) tooLarge = true;
|
|
110
|
+
});
|
|
111
|
+
req.on?.('end', () => {
|
|
112
|
+
if (tooLarge) {
|
|
113
|
+
send(res, 413, { error: 'body_too_large' });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
let parsed = null;
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(body || '{}');
|
|
119
|
+
} catch {
|
|
120
|
+
send(res, 400, { error: 'invalid_json' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const wantsModules = parsed?.modules && typeof parsed.modules === 'object';
|
|
124
|
+
const wantsAutoInject = typeof parsed?.auto_inject === 'boolean';
|
|
125
|
+
if (!wantsModules && !wantsAutoInject) {
|
|
126
|
+
send(res, 400, { error: 'config_required' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (wantsModules) {
|
|
130
|
+
const bad = Object.keys(parsed.modules).filter(
|
|
131
|
+
(key) => MODULE_KEYS.includes(key) && typeof parsed.modules[key] !== 'boolean',
|
|
132
|
+
);
|
|
133
|
+
if (bad.length > 0) {
|
|
134
|
+
send(res, 400, { error: 'module_value_not_boolean', keys: bad });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const written = onConfig?.({ ...parsed }) ?? null;
|
|
140
|
+
const next = written ?? snapshot();
|
|
141
|
+
send(res, 200, {
|
|
142
|
+
ok: true,
|
|
143
|
+
auto_inject: next.auto_inject,
|
|
144
|
+
auto_inject_source: 'ui-config',
|
|
145
|
+
modules: next.modules,
|
|
146
|
+
modules_source: 'ui-config',
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
send(res, 500, { error: 'write_failed', message: String(err?.message ?? err) });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (req.method && req.method !== 'GET') {
|
|
155
|
+
send(res, 405, { error: 'method_not_allowed' });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const state = snapshot();
|
|
160
|
+
send(
|
|
161
|
+
res,
|
|
162
|
+
200,
|
|
163
|
+
buildSummary(main, {
|
|
164
|
+
dataDir,
|
|
165
|
+
modules: state.modules,
|
|
166
|
+
modulesSource: state.source,
|
|
167
|
+
autoInject: state.auto_inject,
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
send(res, 503, { error: 'summary_unavailable', message: String(err?.code ?? err?.message ?? err) });
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { readUiConfig, resolveModulesDetail, writeUiConfig };
|
package/src/runtime/tools.mjs
CHANGED
|
@@ -246,8 +246,22 @@ function textRender(value) {
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
/** Plain tool objects before the host wraps them with defineTool. */
|
|
249
|
-
export function
|
|
250
|
-
|
|
249
|
+
export function enabledToolNames(modules = null) {
|
|
250
|
+
const enabled = modules ?? { context: true, read: true, write: true, dream: true, projects: true, due: true, feed: true };
|
|
251
|
+
return MEMSYS_TOOLS.filter((name) => {
|
|
252
|
+
if (['memsys_get', 'memsys_search', 'memsys_review_get', 'memsys_context_get', 'memsys_self_get'].includes(name)) {
|
|
253
|
+
return enabled.read !== false;
|
|
254
|
+
}
|
|
255
|
+
if (name === 'memsys_project_get') return enabled.read !== false && enabled.projects !== false;
|
|
256
|
+
if (['memsys_dream_record', 'memsys_organize'].includes(name)) return enabled.dream !== false;
|
|
257
|
+
if (name === 'memsys_feed') return enabled.feed !== false;
|
|
258
|
+
if (name === 'memsys_record') return true; // registered but call-gated for an explicit module_disabled
|
|
259
|
+
return true;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function toolDefinitions(handlers = {}, modules = null) {
|
|
264
|
+
return enabledToolNames(modules).map((name) => {
|
|
251
265
|
const spec = TOOL_SPECS[name];
|
|
252
266
|
return {
|
|
253
267
|
name,
|
|
@@ -255,6 +269,9 @@ export function toolDefinitions(handlers = {}) {
|
|
|
255
269
|
parameters: spec.parameters,
|
|
256
270
|
outputSchema: TOOL_OUTPUT_SCHEMAS[name] ?? openObject(),
|
|
257
271
|
async execute(args, exec) {
|
|
272
|
+
if (name === 'memsys_record' && modules?.write === false) {
|
|
273
|
+
return { status: 'error', code: 'module_disabled', module: 'write' };
|
|
274
|
+
}
|
|
258
275
|
const handler = handlers[name];
|
|
259
276
|
if (typeof handler !== 'function') {
|
|
260
277
|
return { status: 'error', code: 'handler_unavailable', tool: name };
|
|
@@ -292,7 +309,7 @@ export function toolCapabilityStatus(liveReport = null) {
|
|
|
292
309
|
* Register against a host ctx. No ctx.tools -> tools-only, no throw.
|
|
293
310
|
* `defineTool` is resolved dynamically so offline tests never need the host package.
|
|
294
311
|
*/
|
|
295
|
-
export async function registerHostTools({ ctx = null, handlers = {}, defineTool: injectedDefineTool = null } = {}) {
|
|
312
|
+
export async function registerHostTools({ ctx = null, handlers = {}, modules = null, defineTool: injectedDefineTool = null } = {}) {
|
|
296
313
|
if (!ctx || !ctx.tools || typeof ctx.tools.register !== 'function') {
|
|
297
314
|
return { status: 'tools_only', registered: [], reason: 'no_tools_service', defineTool: null, tools: [...MEMSYS_TOOLS] };
|
|
298
315
|
}
|
|
@@ -313,7 +330,7 @@ export async function registerHostTools({ ctx = null, handlers = {}, defineTool:
|
|
|
313
330
|
}
|
|
314
331
|
const registered = [];
|
|
315
332
|
const errors = [];
|
|
316
|
-
for (const spec of toolDefinitions(handlers)) {
|
|
333
|
+
for (const spec of toolDefinitions(handlers, modules)) {
|
|
317
334
|
try {
|
|
318
335
|
const tool = defineTool({
|
|
319
336
|
name: spec.name,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of truth for module switches.
|
|
3
|
+
*
|
|
4
|
+
* Both halves read this file: the Web data route (`src/runtime/summary.mjs`) and
|
|
5
|
+
* the host-side gates (`src/runtime/modules.mjs` re-exports from here). Nothing
|
|
6
|
+
* else may re-implement the merge — a second copy is how the switch becomes a
|
|
7
|
+
* lie.
|
|
8
|
+
*
|
|
9
|
+
* Priority: `<data_dir>/ui-config.json` > profile patch config > default (true).
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
export const UI_CONFIG_FILE = 'ui-config.json';
|
|
15
|
+
export const UI_CONFIG_VERSION = 2;
|
|
16
|
+
export const MODULE_KEYS = Object.freeze(['context', 'read', 'write', 'dream', 'projects', 'due', 'feed']);
|
|
17
|
+
export const DEFAULT_MODULES = Object.freeze({
|
|
18
|
+
context: true,
|
|
19
|
+
read: true,
|
|
20
|
+
write: true,
|
|
21
|
+
dream: true,
|
|
22
|
+
projects: true,
|
|
23
|
+
due: true,
|
|
24
|
+
feed: true,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const warn = (logger, message) => {
|
|
28
|
+
try {
|
|
29
|
+
logger?.warn?.(`[dsh-memsys] ${message}`);
|
|
30
|
+
} catch {
|
|
31
|
+
// a logger that throws must never break boot
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Missing keys mean `true`; only an explicit `false` disables a module. */
|
|
36
|
+
export function normalizeModules(input = {}) {
|
|
37
|
+
const modules = { ...DEFAULT_MODULES };
|
|
38
|
+
if (input && typeof input === 'object') {
|
|
39
|
+
for (const key of MODULE_KEYS) {
|
|
40
|
+
if (typeof input[key] === 'boolean') modules[key] = input[key];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return modules;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* v2 is `{ version: 2, auto_inject, modules }`.
|
|
48
|
+
* v1 (or versionless) files only carry `auto_inject`; they migrate by mapping it
|
|
49
|
+
* onto `modules.context` while every other module stays on.
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeUiConfig(parsed, logger = console) {
|
|
52
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
53
|
+
const version = parsed.version;
|
|
54
|
+
const known = version === undefined || version === 1 || version === UI_CONFIG_VERSION;
|
|
55
|
+
if (!known) {
|
|
56
|
+
warn(logger, `ui-config 版本不认识(${version}),已忽略`);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const hasModules = parsed.modules && typeof parsed.modules === 'object';
|
|
60
|
+
if (version === undefined && typeof parsed.auto_inject !== 'boolean' && !hasModules) {
|
|
61
|
+
warn(logger, 'ui-config 既无 auto_inject 也无 modules,已忽略');
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const modules = normalizeModules(hasModules ? parsed.modules : {});
|
|
65
|
+
if (!hasModules && typeof parsed.auto_inject === 'boolean') modules.context = parsed.auto_inject;
|
|
66
|
+
return {
|
|
67
|
+
version: UI_CONFIG_VERSION,
|
|
68
|
+
auto_inject: modules.context,
|
|
69
|
+
modules,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readUiConfig(dataDir, logger = console) {
|
|
74
|
+
if (!dataDir) return null;
|
|
75
|
+
const file = path.join(dataDir, UI_CONFIG_FILE);
|
|
76
|
+
let raw = null;
|
|
77
|
+
try {
|
|
78
|
+
if (!fs.existsSync(file)) return null;
|
|
79
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
80
|
+
} catch (err) {
|
|
81
|
+
warn(logger, `ui-config 读取失败(${err?.message ?? err}),回落 profile 配置`);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return normalizeUiConfig(JSON.parse(raw), logger);
|
|
86
|
+
} catch {
|
|
87
|
+
warn(logger, 'ui-config 不是合法 JSON,回落 profile 配置');
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pure merge used by every caller: defaults <- profile config <- ui-config.
|
|
94
|
+
* A legacy `auto_inject` fills `modules.context` only when that source says
|
|
95
|
+
* nothing about `modules` at all.
|
|
96
|
+
*/
|
|
97
|
+
export function mergeModules(profileConfig = null, uiConfig = null) {
|
|
98
|
+
const base = { ...DEFAULT_MODULES };
|
|
99
|
+
const apply = (source) => {
|
|
100
|
+
if (!source || typeof source !== 'object') return;
|
|
101
|
+
const modules = source.modules;
|
|
102
|
+
if (modules && typeof modules === 'object') {
|
|
103
|
+
for (const key of MODULE_KEYS) {
|
|
104
|
+
if (typeof modules[key] === 'boolean') base[key] = modules[key];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (typeof source.auto_inject === 'boolean' && modules?.context === undefined) {
|
|
108
|
+
base.context = source.auto_inject;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
apply(profileConfig);
|
|
112
|
+
apply(uiConfig);
|
|
113
|
+
return base;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Kept under the name the host gates already import. */
|
|
117
|
+
export function resolveModules(profileConfig = null, uiConfig = null) {
|
|
118
|
+
return Object.freeze(mergeModules(profileConfig, uiConfig));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function moduleEnabled(modules, key) {
|
|
122
|
+
return (modules ?? DEFAULT_MODULES)[key] !== false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const profileSaysSomething = (profileConfig) =>
|
|
126
|
+
Boolean(
|
|
127
|
+
profileConfig &&
|
|
128
|
+
(profileConfig.modules && typeof profileConfig.modules === 'object'
|
|
129
|
+
? MODULE_KEYS.some((key) => typeof profileConfig.modules[key] === 'boolean')
|
|
130
|
+
: typeof profileConfig.auto_inject === 'boolean'),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve for the Web route: returns the effective map plus which rank won,
|
|
135
|
+
* so the settings page can show where the value came from.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveModulesDetail({ dataDir = null, profileConfig = null, logger = console } = {}) {
|
|
138
|
+
const stored = readUiConfig(dataDir, logger);
|
|
139
|
+
const modules = mergeModules(profileConfig, stored);
|
|
140
|
+
const source = stored ? 'ui-config' : profileSaysSomething(profileConfig) ? 'profile' : 'default';
|
|
141
|
+
return { modules, source, auto_inject: modules.context };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Partial update: sends only the keys it owns. A legacy `auto_inject` maps onto
|
|
146
|
+
* `modules.context`; sending `modules.context` mirrors back into `auto_inject`
|
|
147
|
+
* so v1 readers keep working.
|
|
148
|
+
*/
|
|
149
|
+
export function writeUiConfig(dataDir, patch = {}, logger = console) {
|
|
150
|
+
if (!dataDir) return null;
|
|
151
|
+
const current = readUiConfig(dataDir, logger) ?? {
|
|
152
|
+
version: UI_CONFIG_VERSION,
|
|
153
|
+
auto_inject: DEFAULT_MODULES.context,
|
|
154
|
+
modules: { ...DEFAULT_MODULES },
|
|
155
|
+
};
|
|
156
|
+
const next = { ...current.modules };
|
|
157
|
+
if (patch.modules && typeof patch.modules === 'object') {
|
|
158
|
+
for (const key of MODULE_KEYS) {
|
|
159
|
+
if (typeof patch.modules[key] === 'boolean') next[key] = patch.modules[key];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (typeof patch.auto_inject === 'boolean' && patch.modules?.context === undefined) {
|
|
163
|
+
next.context = patch.auto_inject;
|
|
164
|
+
}
|
|
165
|
+
const normalized = {
|
|
166
|
+
version: UI_CONFIG_VERSION,
|
|
167
|
+
auto_inject: next.context,
|
|
168
|
+
modules: next,
|
|
169
|
+
};
|
|
170
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
171
|
+
const target = path.join(dataDir, UI_CONFIG_FILE);
|
|
172
|
+
const temp = `${target}.tmp`;
|
|
173
|
+
fs.writeFileSync(temp, JSON.stringify(normalized, null, 2), 'utf8');
|
|
174
|
+
fs.renameSync(temp, target);
|
|
175
|
+
return normalized;
|
|
176
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"capturedAt": "2026-11-02T17:32:36.368Z",
|
|
3
|
+
"source": "P3-C1 pre-flight (plan-3 §2/§5.4)",
|
|
4
|
+
"protected_inputs": {
|
|
5
|
+
"whale-unify/memsys-design-v0.38.md": {
|
|
6
|
+
"bytes": 99880,
|
|
7
|
+
"sha256": "511cbd8ce3db4ff92706c641449ad83b195d4bd37f17b1b50a43e4847f028ac9"
|
|
8
|
+
},
|
|
9
|
+
"whale-unify/_build/contract.md": {
|
|
10
|
+
"bytes": 3520,
|
|
11
|
+
"sha256": "e3efb6588da5550cc1a526a31f0375b10abfc314a921ea8342a4b0d25396ff10"
|
|
12
|
+
},
|
|
13
|
+
"whale-unify/_build/plan-1.md": {
|
|
14
|
+
"bytes": 65433,
|
|
15
|
+
"sha256": "8b6572a59f9c968844f2a524202bf61bfb2a39f6d095961d6f849387cdfcf999"
|
|
16
|
+
},
|
|
17
|
+
"whale-unify/_build/plan-2.md": {
|
|
18
|
+
"bytes": 61363,
|
|
19
|
+
"sha256": "49e1cdec8f6fda36e40e27c0d1ce90e167c2cd77002608b8864b40fd3061fdc2"
|
|
20
|
+
},
|
|
21
|
+
"whale-unify/_build/plan-3.md": {
|
|
22
|
+
"bytes": 64985,
|
|
23
|
+
"sha256": "498550e1fe24fd075c43940a59dcf47d18ae19b7a914e2b0be917cf33578b3d6"
|
|
24
|
+
},
|
|
25
|
+
"whale-unify/_build/report-1.md": {
|
|
26
|
+
"bytes": 3448,
|
|
27
|
+
"sha256": "9a94435b1e61c2cd6c1dfe347eaac6669657239a364fcc3597c2892550e4d736"
|
|
28
|
+
},
|
|
29
|
+
"whale-unify/_build/report-2.md": {
|
|
30
|
+
"bytes": 3572,
|
|
31
|
+
"sha256": "42e00e3c7fbc50b3512362d2af8bfa68995fcb53faacd5f9c1d82439cb5e0c12"
|
|
32
|
+
},
|
|
33
|
+
"whale-unify/_build/decisions.md": {
|
|
34
|
+
"bytes": 10587,
|
|
35
|
+
"sha256": "2cbdda67546ea3b1dd8ded1b3355d47148c43cdad0d0bbe0a962e403debb434e"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"migrations": {
|
|
39
|
+
"control-001.sql": {
|
|
40
|
+
"bytes": 3504,
|
|
41
|
+
"sha256": "1b1e865dee83757213e7b5b09cd20d0ff19b4dd5ec53efb495205255b32e9f93"
|
|
42
|
+
},
|
|
43
|
+
"control-002.sql": {
|
|
44
|
+
"bytes": 3268,
|
|
45
|
+
"sha256": "df684d926d49b0c209d05ede4b533f46f0942ca80866a3d4bf54d05eb06d493c"
|
|
46
|
+
},
|
|
47
|
+
"control-003.sql": {
|
|
48
|
+
"bytes": 351,
|
|
49
|
+
"sha256": "7cf225bb30a80fb5ec7655bc6ad26ec5e296fd9edb86ea162770d3c418b56e32"
|
|
50
|
+
},
|
|
51
|
+
"dream-001.sql": {
|
|
52
|
+
"bytes": 370,
|
|
53
|
+
"sha256": "ac87b2becc5636645a4dcd14947040503091529eef114e9f589bea56ea1cc31e"
|
|
54
|
+
},
|
|
55
|
+
"main-001.sql": {
|
|
56
|
+
"bytes": 14497,
|
|
57
|
+
"sha256": "062d36f2651fc0ef55c3b329dd00ffff45ad0564d40d82fedffa7dfe75391bee"
|
|
58
|
+
},
|
|
59
|
+
"main-002.sql": {
|
|
60
|
+
"bytes": 3023,
|
|
61
|
+
"sha256": "dbbe52c06365c5903130283d22c295d0cc06492a6d9ee6a5f1702af6f789d96b"
|
|
62
|
+
},
|
|
63
|
+
"main-003.sql": {
|
|
64
|
+
"bytes": 706,
|
|
65
|
+
"sha256": "315330ffc9ffcfeecec7bf841688228630fd105b421d167ec080b559703ab633"
|
|
66
|
+
},
|
|
67
|
+
"main-004.sql": {
|
|
68
|
+
"bytes": 1285,
|
|
69
|
+
"sha256": "0a585a2115325db8d413e45ae97d287e77af061f435c77b528e9460dd1c33bba"
|
|
70
|
+
},
|
|
71
|
+
"main-005.sql": {
|
|
72
|
+
"bytes": 4675,
|
|
73
|
+
"sha256": "686a185473ed35c714768c9106a0f5165a14ee05a6f89c76be457894b0c81258"
|
|
74
|
+
},
|
|
75
|
+
"main-006.sql": {
|
|
76
|
+
"bytes": 1162,
|
|
77
|
+
"sha256": "695f0ad844bd6fb91813fc785ddebe1b1e6fa741300e89aff214250bd6389c1f"
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"expected_versions_before": {
|
|
81
|
+
"main": 6,
|
|
82
|
+
"control": 3,
|
|
83
|
+
"dream": 1
|
|
84
|
+
},
|
|
85
|
+
"expected_versions_c1": {
|
|
86
|
+
"main": 7,
|
|
87
|
+
"control": 4,
|
|
88
|
+
"dream": 2
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -500,7 +500,7 @@ test('P3-C1 migration contract', async (t) => {
|
|
|
500
500
|
}
|
|
501
501
|
assert.deepEqual(versions, { main: 8, control: 4, dream: 2 });
|
|
502
502
|
const baseline = JSON.parse(
|
|
503
|
-
fs.readFileSync(path.join(ROOT, '
|
|
503
|
+
fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'p3', 'c1-baseline.json'), 'utf8'),
|
|
504
504
|
);
|
|
505
505
|
for (const [name, entry] of Object.entries(baseline.migrations)) {
|
|
506
506
|
if (name === 'control-004.sql' || name === 'main-007.sql' || name === 'dream-002.sql') continue;
|
|
@@ -76,7 +76,13 @@ test('P4/P4-B E10: receipts must match the persistent manifest and groups', (t)
|
|
|
76
76
|
receivedIds: expected,
|
|
77
77
|
payloadHash,
|
|
78
78
|
});
|
|
79
|
-
|
|
79
|
+
if (compiled.manifest.actual_ids.filter((id) => id.startsWith('mem_')).length === 0) {
|
|
80
|
+
// Design §6.2c: an empty actual set can never be confirmed, even if the receipt equals it.
|
|
81
|
+
assert.equal(full.delivery, 'incomplete', JSON.stringify(full));
|
|
82
|
+
assert.equal(full.code, 'receipt_incomplete');
|
|
83
|
+
} else {
|
|
84
|
+
assert.equal(full.delivery, 'confirmed', JSON.stringify(full));
|
|
85
|
+
}
|
|
80
86
|
const exposure = ctx.main.prepare('SELECT memory_id FROM injections').all().map((row) => row.memory_id);
|
|
81
87
|
assert.equal(exposure.length, 0, 'deferred members must not produce exposure');
|
|
82
88
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
insertCandidate,
|
|
6
|
+
insertMemory,
|
|
7
|
+
insertObservation,
|
|
8
|
+
insertPointer,
|
|
9
|
+
insertTimeline,
|
|
10
|
+
} from '../src/storage/repositories.mjs';
|
|
11
|
+
import { mergeExact } from '../src/dream/organize.mjs';
|
|
12
|
+
import { createTestContext, makeCandidate, makeMemory, makeObservation, mutableClock } from './helpers/fixtures.mjs';
|
|
13
|
+
import { fakeRuntime } from './helpers/runtime.mjs';
|
|
14
|
+
|
|
15
|
+
let seq = 0;
|
|
16
|
+
|
|
17
|
+
function memory(ctx, { type = 'fact', dueState = 'none', deadline = null } = {}) {
|
|
18
|
+
seq += 1;
|
|
19
|
+
const text = '完全相同的合并记忆';
|
|
20
|
+
const observation = makeObservation(ctx, { messageSeq: 25000 + seq, text });
|
|
21
|
+
insertObservation(ctx.main, observation);
|
|
22
|
+
const candidate = makeCandidate(ctx, observation, { businessKey: `p6b1-${seq}`, text, why: 'w', type, scene: 'work', scope: 'work-only' });
|
|
23
|
+
Object.assign(candidate, { key_status: 'resolved', binding_status: 'accepted', binding_basis: '{}', origin_mode: 'self', semantic_status: 'quoted', record_kind: 'utterance' });
|
|
24
|
+
insertCandidate(ctx.main, candidate);
|
|
25
|
+
const row = makeMemory(ctx, candidate);
|
|
26
|
+
Object.assign(row, {
|
|
27
|
+
key_status: 'resolved', binding_status: 'accepted', binding_basis: '{}', origin_mode: 'self',
|
|
28
|
+
semantic_status: 'quoted', record_kind: 'utterance', due_state: dueState,
|
|
29
|
+
});
|
|
30
|
+
insertMemory(ctx.main, row);
|
|
31
|
+
insertTimeline(ctx.main, { memory_id: row.id, start_at: null, end_at: null, prec_start: 'unknown', prec_end: 'unknown', time_state: 'point', revision: 1 });
|
|
32
|
+
insertPointer(ctx.main, { memory_id: row.id, status: 'unknown', location: null, search_hint: null, lossy: 0, revision: 1 });
|
|
33
|
+
if (deadline) {
|
|
34
|
+
ctx.main
|
|
35
|
+
.prepare("INSERT INTO due_queue (memory_id, due_state, enqueued_seq, enqueued_at, opened_at, deadline_at, revision, reason) VALUES (?, 'none', 1, ?, NULL, ?, 0, 'test')")
|
|
36
|
+
.run(row.id, ctx.clock.at().at, deadline);
|
|
37
|
+
}
|
|
38
|
+
return row;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function merge(ctx, a, b) {
|
|
42
|
+
return mergeExact(
|
|
43
|
+
{ main: ctx.main, control: ctx.control, clock: ctx.clock },
|
|
44
|
+
{ runtime: fakeRuntime({ role: 'dream', callId: 'p6b1' }), ids: [a.id, b.id] },
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('P6/B1: promise, done/dropped and non-empty due are blocked from merge', (t) => {
|
|
49
|
+
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
50
|
+
t.after(() => ctx.cleanup());
|
|
51
|
+
for (const options of [{ type: 'promise' }, { dueState: 'done' }, { dueState: 'dropped' }, { deadline: '2026-12-01T00:00:00Z' }]) {
|
|
52
|
+
const a = memory(ctx, options);
|
|
53
|
+
const b = memory(ctx, options);
|
|
54
|
+
assert.throws(
|
|
55
|
+
() => merge(ctx, a, b),
|
|
56
|
+
(err) => err.code === 'blocked_by_due_state',
|
|
57
|
+
JSON.stringify(options),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
import { applyPlan, buildPlan, verifyPlan } from '../src/import/legacy.mjs';
|
|
9
|
+
import { applyPointers, planPointers } from '../src/import/pointers.mjs';
|
|
10
|
+
import { resolveConfig } from '../src/config.mjs';
|
|
11
|
+
import { closeAll, openAll } from '../src/storage/open.mjs';
|
|
12
|
+
import { migrateAll } from '../src/storage/migrate.mjs';
|
|
13
|
+
import { seedInitialRegistry } from '../src/storage/repositories.mjs';
|
|
14
|
+
import { canonicalJson } from '../src/identity.mjs';
|
|
15
|
+
import { mutableClock } from './helpers/fixtures.mjs';
|
|
16
|
+
|
|
17
|
+
function legacyCtx() {
|
|
18
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memsys-p6b2-'));
|
|
19
|
+
const source = path.join(dir, 'source');
|
|
20
|
+
fs.mkdirSync(source, { recursive: true });
|
|
21
|
+
const clock = mutableClock().clock;
|
|
22
|
+
const handles = openAll(resolveConfig({ dataDir: path.join(dir, 'target') }));
|
|
23
|
+
migrateAll(handles, { clock });
|
|
24
|
+
handles.main.exec('BEGIN IMMEDIATE');
|
|
25
|
+
seedInitialRegistry(handles.main, clock);
|
|
26
|
+
handles.main.exec('COMMIT');
|
|
27
|
+
return {
|
|
28
|
+
dir, source, handles, clock,
|
|
29
|
+
deps: { main: handles.main, control: handles.control, clock },
|
|
30
|
+
cleanup() { try { closeAll(handles); } catch {} fs.rmSync(dir, { recursive: true, force: true }); },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('P6/B2: changing a source byte blocks pointer apply before relation write', (t) => {
|
|
35
|
+
const ctx = legacyCtx();
|
|
36
|
+
t.after(() => ctx.cleanup());
|
|
37
|
+
const raw = '真实原文';
|
|
38
|
+
const record = { id: 'b2p', kind: 'point', raw, gist: '摘要', why: 'w', type: 'fact', scene: 'work', scope: 'work-only', pointer: { status: 'unknown' } };
|
|
39
|
+
fs.writeFileSync(path.join(ctx.source, 'legacy.json'), JSON.stringify({ records: [record], graph: { nodes: [], edges: [] } }), 'utf8');
|
|
40
|
+
const plan = buildPlan(ctx.source);
|
|
41
|
+
applyPlan(ctx.deps, plan);
|
|
42
|
+
const item = plan.items.find((entry) => entry.itemKind === 'memory');
|
|
43
|
+
const bytes = Buffer.from(raw, 'utf8');
|
|
44
|
+
const file = path.join(ctx.source, 'b2p.md');
|
|
45
|
+
fs.writeFileSync(file, bytes);
|
|
46
|
+
const versions = path.join(ctx.source, 'versions.json');
|
|
47
|
+
fs.writeFileSync(versions, JSON.stringify({ versions: [{
|
|
48
|
+
sourceObjectId: 'b2p', versionHash: item.sourceVersionHash,
|
|
49
|
+
location: file + '#bytes:0-' + bytes.length,
|
|
50
|
+
file, span: { start: 0, end: bytes.length },
|
|
51
|
+
spanHash: crypto.createHash('sha256').update(bytes).digest('hex'),
|
|
52
|
+
}] }), 'utf8');
|
|
53
|
+
const pointerPlan = planPointers({ main: ctx.handles.main, clock: ctx.clock }, versions);
|
|
54
|
+
assert.equal(pointerPlan.items.length, 1, JSON.stringify(pointerPlan.skipped));
|
|
55
|
+
fs.writeFileSync(file, Buffer.concat([bytes, Buffer.from('x')]));
|
|
56
|
+
assert.throws(
|
|
57
|
+
() => applyPointers({ main: ctx.handles.main, clock: ctx.clock }, pointerPlan),
|
|
58
|
+
(err) => err.code === 'source_bytes_changed',
|
|
59
|
+
);
|
|
60
|
+
const changes = ctx.handles.main.prepare("SELECT COUNT(*) AS n FROM relations WHERE type = 'pointer-change' AND json_extract(payload, '$.basis') = 'pointer-backfill'").get().n;
|
|
61
|
+
assert.equal(Number(changes), 0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('P6/B2: changed graph node label makes verifyPlan fail', (t) => {
|
|
65
|
+
const ctx = legacyCtx();
|
|
66
|
+
t.after(() => ctx.cleanup());
|
|
67
|
+
const record = { id: 'b2g', kind: 'point', raw: '文本', gist: '摘要', why: 'w', type: 'fact', scene: 'work', scope: 'work-only', pointer: { status: 'unknown' } };
|
|
68
|
+
const graph = { nodes: [{ id: 'n1', label: '旧标签', kind: 'concept' }], edges: [] };
|
|
69
|
+
fs.writeFileSync(path.join(ctx.source, 'legacy.json'), JSON.stringify({ records: [record], graph }), 'utf8');
|
|
70
|
+
const plan = buildPlan(ctx.source);
|
|
71
|
+
applyPlan(ctx.deps, plan);
|
|
72
|
+
ctx.handles.main.exec('DROP TRIGGER IF EXISTS trg_graph_nodes_no_update');
|
|
73
|
+
ctx.handles.main.prepare('UPDATE graph_nodes SET label = ? WHERE id = ?').run('新标签', 'n1');
|
|
74
|
+
const verify = verifyPlan({ main: ctx.handles.main, clock: ctx.clock }, plan);
|
|
75
|
+
assert.equal(verify.status, 'failed');
|
|
76
|
+
assert.ok(verify.missing.some((entry) => entry.includes('graph_node_content')), JSON.stringify(verify.missing));
|
|
77
|
+
});
|