@rennimars/dsh-weknora-memory 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,468 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 夜间画像写入器:data/profile.json 的唯一写入者。插件侧只读,任何新内容只能从这里
4
+ * 进入常驻层。这是安全边界——常驻内容每轮注入、跨会话传播,一旦被污染没有任何
5
+ * 后续检索能纠正它。
6
+ *
7
+ * 规则:
8
+ * - 新卡一律 status="proposed",proposedAt 记当天;当晚的复核不会把它转正(必须等下一夜)。
9
+ * - 复核只处理 proposedAt < 当天 的卡,且必须同时满足:有来源、不重复(id 与正文)、
10
+ * 同 id 不与其他卡冲突、正文 ≤60 字。任一条不过就保持 proposed 并写进日志。
11
+ * - 降级:读 config.profile.statsPath,只有插件按 profile.degradeAfter 写了 degrade 标记的
12
+ * active 卡才转 disputed(下一轮就不再进常驻块,插件按 status 过滤)。没有 degrade 标记的卡,
13
+ * 即使 missStreak 早已过线,也只作为"候选"报告,不动 status——判据只有插件,不是本脚本的自算。
14
+ * - 默认 dry-run:打印计划与 diff,不落盘;--write 才写。
15
+ *
16
+ * 本脚本不调用 WeKnora:它只读/写本地 profile.json 与 profile-stats.json。
17
+ *
18
+ * 用法:
19
+ * node writers/profile-writer.mjs --propose "用户偏好 Java 后端" --kind user --source m00123 --dry-run
20
+ * node writers/profile-writer.mjs --review --dry-run
21
+ * node writers/profile-writer.mjs --review --write
22
+ */
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import crypto from 'node:crypto';
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+ import { selectProfileCardsDetailed } from '../lib/format.js';
28
+
29
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
30
+ const ROOT = path.resolve(HERE, '..');
31
+ const DEFAULT_CONFIG = path.join(HERE, 'writer.config.json');
32
+ const EXAMPLE_CONFIG = path.join(HERE, 'config.example.json');
33
+
34
+ /**
35
+ * 与插件侧硬约束一致(lib/config.js 的 PROFILE_* 常量):
36
+ * 9 张卡总量、user 3 张 / agent 6 张、单卡 80 字、user 文本 200 字、agent 文本 400 字。
37
+ * 字数按 kind 分账——共用一个池子时三张 user 卡会把 agent 层挤成 0 张,这是实际发生过的事。
38
+ */
39
+ export const MAX_CARDS = 9;
40
+ export const PER_KIND_MAX = { user: 3, agent: 6 };
41
+ export const MAX_TEXT = 80;
42
+ export const USER_MAX_CHARS = 200;
43
+ export const AGENT_MAX_CHARS = 400;
44
+ /**
45
+ * 复核转正的正文上限(任务契约:正文 ≤60 字)。比写卡上限 80 更严:
46
+ * 常驻层每轮注入,越短越不容易把一整段结论挤成噪声。
47
+ */
48
+ export const REVIEW_TEXT_MAX = 60;
49
+ /**
50
+ * R6 的历史阈值,保留但不再是判据:它只用来把"连续 missStreak 已到线、插件却没写 degrade 标记"
51
+ * 的卡单独报成候选。真正决定降级的是 profile-stats.json 里的 degrade 字段(插件按
52
+ * profile.degradeAfter 写;出厂 0 表示只计数、永不标记,于是夜间任务一张也不会降级)。
53
+ */
54
+ export const DEGRADE_AFTER = 50;
55
+
56
+ const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
57
+ const charLen = (value) => Array.from(clean(value)).length;
58
+ const KINDS = new Set(['user', 'agent']);
59
+ const KIND_CHARS = { user: USER_MAX_CHARS, agent: AGENT_MAX_CHARS };
60
+ /**
61
+ * 归一化主题键:去掉标点与空白后比对。同一件事被反复提出时只有这个键相同,正文常常
62
+ * 一字不差地重写——按 key 合并而不是追加。
63
+ */
64
+ const PUNCT = /[\s,。;:、,.;:!?!?"'“”‘’()()【】\[\]—–-]+/g;
65
+ export function keyOf(value) {
66
+ return clean(value).toLowerCase().replace(PUNCT, '');
67
+ }
68
+
69
+ /** 当前 kind 的 core 文本占用(disputed 不算,因为它就不再注入了)。 */
70
+ function kindChars(cards, kind) {
71
+ return cards.filter((card) => card?.kind === kind && card?.core === true && card?.status !== 'disputed')
72
+ .reduce((sum, card) => sum + charLen(card?.text), 0);
73
+ }
74
+ const STATUSES = new Set(['active', 'proposed', 'disputed']);
75
+
76
+ export function dayOf(now = new Date()) {
77
+ const pad = (n) => String(n).padStart(2, '0');
78
+ return now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate());
79
+ }
80
+
81
+ export function emptyProfile() {
82
+ return { version: 1, cards: [] };
83
+ }
84
+
85
+ /** 读一个 profile.json;缺失或损坏都退化成空表(插件侧同样如此,写侧不能更严格)。 */
86
+ export function readProfile(file) {
87
+ try {
88
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
89
+ const cards = Array.isArray(raw?.cards) ? raw.cards : [];
90
+ return { version: Number(raw?.version) || 1, cards };
91
+ } catch {
92
+ return emptyProfile();
93
+ }
94
+ }
95
+
96
+ export function serializeProfile(profile) {
97
+ return JSON.stringify({ version: Number(profile?.version) || 1, cards: profile?.cards ?? [] }, null, 2) + '\n';
98
+ }
99
+
100
+ /** 行级 diff:只裁掉公共前后缀,中间整段按 -/+ 输出,够看清楚这一次改了什么。 */
101
+ export function diffTexts(before, after) {
102
+ const a = String(before).split('\n');
103
+ const b = String(after).split('\n');
104
+ let start = 0;
105
+ while (start < a.length && start < b.length && a[start] === b[start]) start += 1;
106
+ let endA = a.length - 1;
107
+ let endB = b.length - 1;
108
+ while (endA >= start && endB >= start && a[endA] === b[endB]) { endA -= 1; endB -= 1; }
109
+ const out = [];
110
+ for (let i = start; i <= endA; i += 1) out.push('- ' + a[i]);
111
+ for (let i = start; i <= endB; i += 1) out.push('+ ' + b[i]);
112
+ return out.join('\n');
113
+ }
114
+
115
+ /** approve/reject:人的一次显式决定,写进卡里而不是写进某个进程的内存。 */
116
+ export function decide(profile, id, action, { today = dayOf() } = {}) {
117
+ const cards = Array.isArray(profile?.cards) ? profile.cards.map((card) => ({ ...card })) : [];
118
+ const index = cards.findIndex((card) => card?.id === id);
119
+ if (index < 0) return { error: 'unknown_id:' + id };
120
+ if (action === 'approve') {
121
+ cards[index] = { ...cards[index], approve: true, approvedAt: today };
122
+ } else if (action === 'reject') {
123
+ cards[index] = { ...cards[index], status: 'disputed', rejectedAt: today };
124
+ } else {
125
+ return { error: 'bad_action:' + action };
126
+ }
127
+ return { card: cards[index], profile: { version: Number(profile?.version) || 1, cards } };
128
+ }
129
+
130
+ /**
131
+ * 生成一张 proposed 卡。所有校验都在这里,不合格就返回 error 且不产生卡:
132
+ * 正文 1..80 字、kind 已知、有来源、id 与正文都不重复、不超 per-kind 3 张与 core 200 字。
133
+ */
134
+ export function propose(profile, input = {}, { today = dayOf() } = {}) {
135
+ const cards = Array.isArray(profile?.cards) ? profile.cards.slice() : [];
136
+ const text = clean(input.text);
137
+ const kind = clean(input.kind) || 'user';
138
+ const source = clean(input.source);
139
+ const core = input.core !== false;
140
+ if (!text) return { error: 'empty_text' };
141
+ if (charLen(text) > MAX_TEXT) return { error: 'text_too_long:' + charLen(text) };
142
+ if (!KINDS.has(kind)) return { error: 'bad_kind:' + kind };
143
+ if (!source) return { error: 'no_source' };
144
+ // Same topic, same kind: attest the existing card instead of appending a near-copy.
145
+ const key = keyOf(input.key || text);
146
+ const index = cards.findIndex((card) => card?.kind === kind && (clean(card?.key) ? card.key === key : keyOf(card?.text) === key));
147
+ if (index >= 0) {
148
+ const prev = cards[index];
149
+ const mergedText = charLen(text) > charLen(prev.text) ? text : clean(prev.text);
150
+ if (charLen(mergedText) > MAX_TEXT) return { error: 'text_too_long:' + charLen(mergedText) };
151
+ const merged = {
152
+ ...prev,
153
+ text: mergedText,
154
+ key,
155
+ // An existing card already counts as one sighting: the second one makes two.
156
+ attest: (Number(prev.attest) || 1) + 1,
157
+ lastSeen: today,
158
+ proposedAt: prev.status === 'proposed' ? today : clean(prev.proposedAt),
159
+ };
160
+ cards[index] = merged;
161
+ return { card: merged, merged: true, profile: { version: Number(profile?.version) || 1, cards } };
162
+ }
163
+ const id = clean(input.id) || kind + '.' + crypto.createHash('sha1').update(text, 'utf8').digest('hex').slice(0, 8);
164
+ if (cards.some((card) => card?.id === id)) return { error: 'duplicate_id:' + id };
165
+ const sameKind = cards.filter((card) => card?.kind === kind && card?.core === true && card?.status !== 'disputed').length;
166
+ if (sameKind >= PER_KIND_MAX[kind]) return { error: 'kind_quota:' + kind + '=' + sameKind };
167
+ const liveCore = cards.filter((card) => card?.core === true && card?.status !== 'disputed').length;
168
+ if (liveCore >= MAX_CARDS) return { error: 'card_quota:' + liveCore };
169
+ if (core && kindChars(cards, kind) + charLen(text) > KIND_CHARS[kind]) {
170
+ return { error: 'kind_chars:' + kind + '=' + kindChars(cards, kind) + '+' + charLen(text) + '>' + KIND_CHARS[kind] };
171
+ }
172
+ const card = {
173
+ id,
174
+ kind,
175
+ core,
176
+ key,
177
+ text,
178
+ valid_from: today,
179
+ status: 'proposed',
180
+ source,
181
+ proposedAt: today,
182
+ attest: 1,
183
+ };
184
+ return { card, profile: { version: Number(profile?.version) || 1, cards: [...cards, card] } };
185
+ }
186
+
187
+ /** 重复/冲突判定:同 id、正文完全相同、同 kind 且正文互相包含,都算冲突。 */
188
+ function conflictOf(card, cards) {
189
+ for (const other of cards) {
190
+ if (!other) continue;
191
+ if (other.id === card.id) return 'duplicate_id:' + other.id;
192
+ if (clean(other.text) === clean(card.text)) return 'duplicate_text:' + other.id;
193
+ if (other.kind === card.kind) {
194
+ const a = clean(card.text);
195
+ const b = clean(other.text);
196
+ if (a.length >= 6 && b.length >= 6 && (a.includes(b) || b.includes(a))) return 'overlap:' + other.id;
197
+ }
198
+ }
199
+ return '';
200
+ }
201
+
202
+ /**
203
+ * 一夜的复核:proposed 转 active(必须过四条:有来源 / 不重复 / 同 id 不冲突 / 正文 ≤60 字,
204
+ * 且不是当晚提的);active 只在 profile-stats.json 该卡带 degrade 标记时转 disputed,否则
205
+ * streak 再长也只记一条 candidate 决策。纯函数,返回新 profile 与逐卡决策,便于测试与日志。
206
+ */
207
+ export function review(profile, { today = dayOf(), stats = {}, degrade = true } = {}) {
208
+ const cards = (Array.isArray(profile?.cards) ? profile.cards : []).map((card) => ({ ...card }));
209
+ const decisions = [];
210
+ for (const card of cards) {
211
+ if (card.status === 'proposed') {
212
+ if (!card.proposedAt || String(card.proposedAt) >= today) {
213
+ decisions.push({ id: card.id, action: 'kept_proposed', reason: 'proposed_today_wait_next_night' });
214
+ continue;
215
+ }
216
+ if (!clean(card.source)) {
217
+ decisions.push({ id: card.id, action: 'kept_proposed', reason: 'no_source' });
218
+ continue;
219
+ }
220
+ const length = charLen(card.text);
221
+ if (!length || length > REVIEW_TEXT_MAX) {
222
+ decisions.push({ id: card.id, action: 'kept_proposed', reason: 'bad_text:' + length + '>' + REVIEW_TEXT_MAX });
223
+ continue;
224
+ }
225
+ const others = cards.filter((other) => other !== card);
226
+ const conflict = conflictOf(card, others);
227
+ if (conflict) {
228
+ decisions.push({ id: card.id, action: 'kept_proposed', reason: 'conflict:' + conflict });
229
+ continue;
230
+ }
231
+ card.status = 'active';
232
+ decisions.push({ id: card.id, action: 'activated', reason: 'source+unique+under_' + REVIEW_TEXT_MAX });
233
+ continue;
234
+ }
235
+ if (card.status === 'active' && degrade) {
236
+ const entry = stats?.[card.id] ?? {};
237
+ const miss = Number(entry.missStreak) || 0;
238
+ const marker = entry.degrade;
239
+ if (marker) {
240
+ card.status = 'disputed';
241
+ const after = Number(marker?.after) || 0;
242
+ decisions.push({ id: card.id, action: 'disputed', reason: 'degrade_marker_' + after });
243
+ continue;
244
+ }
245
+ if (miss >= DEGRADE_AFTER) {
246
+ decisions.push({ id: card.id, action: 'candidate', reason: 'miss_streak_' + miss + '_no_marker' });
247
+ }
248
+ }
249
+ }
250
+ return { profile: { version: Number(profile?.version) || 1, cards }, decisions };
251
+ }
252
+
253
+ /**
254
+ * 本轮的预算实况:用插件自己的选择器算一遍,所以"哪张卡被挤掉了"和插件渲染时看到的是
255
+ * 同一套判断。静默丢弃正是要避免的:写满就报错,我们这里改成报表。
256
+ */
257
+ export function profileBudget(cards) {
258
+ const config = {
259
+ profile: {
260
+ enabled: true,
261
+ cards,
262
+ maxCards: MAX_CARDS,
263
+ perKindMax: PER_KIND_MAX,
264
+ maxText: MAX_TEXT,
265
+ userMaxChars: USER_MAX_CHARS,
266
+ agentMaxChars: AGENT_MAX_CHARS,
267
+ },
268
+ };
269
+ const { chosen, chars, dropped, caps } = selectProfileCardsDetailed(config);
270
+ const count = { user: 0, agent: 0 };
271
+ for (const card of chosen) count[card.kind] += 1;
272
+ return { chosen: chosen.length, count, chars, dropped, caps };
273
+ }
274
+
275
+ export function readStats(file) {
276
+ try {
277
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
278
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
279
+ } catch {
280
+ return {};
281
+ }
282
+ }
283
+
284
+ /**
285
+ * 一次夜间运行的完整入口:propose -> review -> (diff, 落盘)。默认 dry-run。
286
+ * @returns { ok, error?, profile, written, decisions, diff }
287
+ */
288
+ export function run(options = {}) {
289
+ const profilePath = options.profilePath;
290
+ const statsPath = options.statsPath;
291
+ const dryRun = options.dryRun !== false;
292
+ const today = options.today || dayOf(options.now ? new Date(options.now) : new Date());
293
+ if (!profilePath) return { ok: false, error: 'no_profile_path', written: false, decisions: [], diff: '' };
294
+ const before = readProfile(profilePath);
295
+ const beforeText = serializeProfile(before);
296
+ let working = before;
297
+ const decisions = [];
298
+ if (options.propose !== undefined && options.propose !== null) {
299
+ const proposed = propose(working, { text: options.propose, kind: options.kind, id: options.id, source: options.source, core: options.core }, { today });
300
+ if (proposed.error) return { ok: false, error: proposed.error, profile: before, written: false, decisions, diff: '' };
301
+ working = proposed.profile;
302
+ decisions.push({ id: proposed.card.id, action: 'proposed', reason: 'awaiting_next_night_review' });
303
+ }
304
+ for (const id of options.approve ?? []) {
305
+ const approved = decide(working, id, 'approve', { today });
306
+ if (approved.error) return { ok: false, error: approved.error, profile: before, written: false, decisions, diff: '' };
307
+ working = approved.profile;
308
+ decisions.push({ id, action: 'approved', reason: 'human_approval' });
309
+ }
310
+ for (const id of options.reject ?? []) {
311
+ const rejected = decide(working, id, 'reject', { today });
312
+ if (rejected.error) return { ok: false, error: rejected.error, profile: before, written: false, decisions, diff: '' };
313
+ working = rejected.profile;
314
+ decisions.push({ id, action: 'rejected', reason: 'human_rejection' });
315
+ }
316
+ const degradeOn = options.degrade !== false;
317
+ let degradeReport = { eligible: 0, degraded: 0, candidates: 0 };
318
+ if (options.review === true) {
319
+ const stats = statsPath ? readStats(statsPath) : {};
320
+ // Eligibility is counted before the flip: a card that carries the plugin's degrade marker
321
+ // is the only one this run may retire, so eligible stays non-zero even under --no-degrade.
322
+ const eligible = degradeOn ? working.cards.filter((card) => card?.status === 'active' && stats?.[card?.id]?.degrade).length : 0;
323
+ const reviewed = review(working, { today, stats, degrade: degradeOn });
324
+ working = reviewed.profile;
325
+ decisions.push(...reviewed.decisions);
326
+ degradeReport = {
327
+ eligible,
328
+ degraded: reviewed.decisions.filter((entry) => entry.action === 'disputed').length,
329
+ candidates: reviewed.decisions.filter((entry) => entry.action === 'candidate').length,
330
+ };
331
+ }
332
+ const budget = profileBudget(working.cards);
333
+ const afterText = serializeProfile(working);
334
+ const diff = diffTexts(beforeText, afterText);
335
+ const changed = diff !== '';
336
+ if (dryRun || !changed) return { ok: true, profile: working, written: false, decisions, diff, profilePath, degradeReport, budget };
337
+ fs.mkdirSync(path.dirname(profilePath), { recursive: true });
338
+ fs.writeFileSync(profilePath, afterText, 'utf8');
339
+ return { ok: true, profile: working, written: true, decisions, diff, profilePath, degradeReport, budget };
340
+ }
341
+
342
+ // ---------- 配置 ----------
343
+ function mergeConfig(base, over) {
344
+ const out = { ...base };
345
+ for (const [key, value] of Object.entries(over || {})) {
346
+ const prev = out[key];
347
+ out[key] = value && typeof value === 'object' && !Array.isArray(value) && prev && typeof prev === 'object'
348
+ ? { ...prev, ...value }
349
+ : value;
350
+ }
351
+ return out;
352
+ }
353
+
354
+ function loadConfig(explicit) {
355
+ if (!fs.existsSync(EXAMPLE_CONFIG)) throw new Error('缺少默认配置:' + EXAMPLE_CONFIG);
356
+ let file = '';
357
+ let fallback = false;
358
+ if (explicit) {
359
+ if (!fs.existsSync(explicit)) throw new Error('配置文件不存在:' + explicit);
360
+ file = explicit;
361
+ } else if (fs.existsSync(DEFAULT_CONFIG)) {
362
+ file = DEFAULT_CONFIG;
363
+ } else {
364
+ file = EXAMPLE_CONFIG;
365
+ fallback = true;
366
+ }
367
+ const defaults = JSON.parse(fs.readFileSync(EXAMPLE_CONFIG, 'utf8'));
368
+ const raw = file === EXAMPLE_CONFIG ? defaults : JSON.parse(fs.readFileSync(file, 'utf8'));
369
+ const cfg = mergeConfig(defaults, raw);
370
+ cfg.__file = file;
371
+ cfg.__fallback = fallback;
372
+ return cfg;
373
+ }
374
+
375
+ const USAGE = [
376
+ '用法:node writers/profile-writer.mjs [选项]',
377
+ '',
378
+ 'data/profile.json 的唯一写入者:新卡一律 proposed,至少等一夜才复核;',
379
+ '复核四条全过才转 active,插件标了 degrade 的 active 卡转 disputed。不调用 WeKnora。',
380
+ '',
381
+ '选项:',
382
+ ' --config <path> 配置文件;默认 writers/writer.config.json,缺省回落到 config.example.json',
383
+ ' --review 跑一夜的复核(proposed -> active / active -> disputed)',
384
+ ' --write 真正落盘;不加则一律 dry-run(只打印计划与 diff)',
385
+ ' --dry-run 显式 dry-run(默认行为,便于脚本统一传参)',
386
+ ' --propose <text> 提议一张新卡(status=proposed,proposedAt=今天)',
387
+ ' --kind <user|agent> 提议的卡类型,默认 user',
388
+ ' --source <ref> 提议卡的来源(必填,如 m00123)',
389
+ ' --id <id> 指定卡 id;默认 kind + 正文 sha1 前 8 位',
390
+ ' --approve <ids> 人工批准,逗号分隔',
391
+ ' --reject <ids> 人工驳回(转 disputed),逗号分隔',
392
+ ' --no-degrade 本轮不做降级,只报告',
393
+ ' --profile <path> 覆盖 config.profile.path(相对包根的相对路径或绝对路径)',
394
+ ' --stats <path> 覆盖 config.profile.statsPath',
395
+ ' --verbose 打印配置解析与预算明细',
396
+ ' -h, --help 打印本帮助并退出 0',
397
+ '',
398
+ '退出码:0 成功;1 运行期失败(配置/IO);2 参数或提议校验失败。',
399
+ ].join('\n');
400
+
401
+ /** 配置里的相对路径按包根目录解析;命令行覆盖值按调用者当前目录解析。 */
402
+ function resolvePath(value, { fromRoot }) {
403
+ const text = String(value || '');
404
+ if (!text) return '';
405
+ return fromRoot ? path.resolve(ROOT, text) : path.resolve(text);
406
+ }
407
+
408
+ function cli(argv) {
409
+ const flag = (name) => argv.includes(name);
410
+ const value = (name) => { const i = argv.indexOf(name); return i >= 0 ? String(argv[i + 1] ?? '') : ''; };
411
+ if (flag('--help') || flag('-h')) {
412
+ console.log(USAGE);
413
+ return 0;
414
+ }
415
+ let cfg;
416
+ try {
417
+ cfg = loadConfig(value('--config'));
418
+ } catch (error) {
419
+ console.error('[fail] ' + (error?.message || error));
420
+ return 1;
421
+ }
422
+ const profilePath = value('--profile') ? resolvePath(value('--profile'), { fromRoot: false }) : resolvePath(cfg.profile?.path || 'data/profile.json', { fromRoot: true });
423
+ const statsPath = value('--stats') ? resolvePath(value('--stats'), { fromRoot: false }) : resolvePath(cfg.profile?.statsPath || 'data/profile-stats.json', { fromRoot: true });
424
+ if (flag('--verbose')) {
425
+ console.log('[config] ' + cfg.__file + (cfg.__fallback ? '(未找到 writer.config.json,回落到 config.example.json 的默认值)' : ''));
426
+ console.log('[config] profile=' + profilePath + ' stats=' + statsPath);
427
+ }
428
+ const result = run({
429
+ profilePath,
430
+ statsPath,
431
+ dryRun: !flag('--write'),
432
+ propose: flag('--propose') ? value('--propose') : undefined,
433
+ kind: value('--kind') || 'user',
434
+ id: value('--id') || undefined,
435
+ source: value('--source') || undefined,
436
+ core: !flag('--no-core'),
437
+ review: flag('--review'),
438
+ degrade: !flag('--no-degrade'),
439
+ approve: value('--approve') ? value('--approve').split(',').map((id) => id.trim()).filter(Boolean) : [],
440
+ reject: value('--reject') ? value('--reject').split(',').map((id) => id.trim()).filter(Boolean) : [],
441
+ });
442
+ const mode = flag('--write') ? 'write' : 'dry-run';
443
+ console.log('[' + mode + '] profile=' + (result.profilePath || profilePath));
444
+ if (!result.ok) {
445
+ console.log('[fail] ' + result.error);
446
+ return 2;
447
+ }
448
+ for (const decision of result.decisions) console.log('[card] ' + decision.id + ' ' + decision.action + ' (' + decision.reason + ')');
449
+ const report = result.degradeReport;
450
+ console.log('[degrade] 具备降级资格(插件 degrade 标记)' + report.eligible + ' 张,实际降级 ' + report.degraded
451
+ + ' 张;仅 missStreak≥' + DEGRADE_AFTER + ' 且无标记的候选 ' + report.candidates + ' 张(status 不变)');
452
+ const budget = result.budget;
453
+ if (budget) {
454
+ const d = budget.dropped;
455
+ const loses = ['status', 'kind', 'notCore', 'quota', 'chars', 'maxCards'].filter((name) => d[name]).map((name) => name + '=' + d[name]).join(' ');
456
+ console.log('[budget] user ' + budget.count.user + '/' + budget.caps.perKindMax.user + ' 张 ' + budget.chars.user + '/' + budget.caps.chars.user
457
+ + ' 字;agent ' + budget.count.agent + '/' + budget.caps.perKindMax.agent + ' 张 ' + budget.chars.agent + '/' + budget.caps.chars.agent
458
+ + ' 字;未进块 ' + (loses || '无'));
459
+ }
460
+ console.log(result.diff ? '[diff]\n' + result.diff : '[diff] (none)');
461
+ console.log(result.written ? '[write] ' + result.profilePath : '[dry-run] nothing written');
462
+ return 0;
463
+ }
464
+
465
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
466
+ process.exitCode = cli(process.argv.slice(2));
467
+ if (STATUSES.size === 0) process.exitCode = 1;
468
+ }
@@ -0,0 +1,71 @@
1
+ <#
2
+ Registers the optional write-side scheduled tasks for this machine.
3
+
4
+ Nothing here runs at install time: the plugin's read path works without any of it,
5
+ and a task that writes into a knowledge base is a decision the user makes, not a
6
+ side effect of an npm install.
7
+
8
+ .\register-tasks.ps1 # register (or update) the tasks
9
+ .\register-tasks.ps1 -Unregister # remove them
10
+ .\register-tasks.ps1 -Prefix WK # rename the task group (default DSH-Memory)
11
+
12
+ Tasks are registered for the current user only: no admin rights, no system-wide
13
+ service. Times can be changed in -Times like @{ Ingest='hourly'; Distill='04:30'; Dream='03:30' }.
14
+ #>
15
+ [CmdletBinding()]
16
+ param(
17
+ [switch]$Unregister,
18
+ [string]$Prefix = 'DSH-Memory',
19
+ [string]$Config = '',
20
+ [hashtable]$Times = @{ Ingest = 'hourly'; Distill = '04:30'; Dream = '03:30' }
21
+ )
22
+
23
+ $ErrorActionPreference = 'Stop'
24
+ $here = Split-Path -Parent $MyInvocation.MyCommand.Path
25
+ $node = (Get-Command node -ErrorAction Stop).Source
26
+
27
+ $names = @{
28
+ Ingest = "$Prefix-Session-Ingest"
29
+ Distill = "$Prefix-Session-Distill"
30
+ Dream = "$Prefix-Memory-Dream"
31
+ }
32
+
33
+ function Get-ArgList([string]$script) {
34
+ # --config is only passed when the caller named one; the scripts find writer.config.json themselves.
35
+ if ($Config) { return @($script, '--config', $Config) }
36
+ return @($script)
37
+ }
38
+
39
+ if ($Unregister) {
40
+ foreach ($name in $names.Values) {
41
+ $existing = Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
42
+ if ($existing) {
43
+ Unregister-ScheduledTask -TaskName $name -Confirm:$false
44
+ Write-Output "removed $name"
45
+ } else {
46
+ Write-Output "not present $name"
47
+ }
48
+ }
49
+ return
50
+ }
51
+
52
+ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 2)
53
+
54
+ function Register-WriterTask([string]$name, [string[]]$arguments, $trigger, [string]$description) {
55
+ $action = New-ScheduledTaskAction -Execute $node -Argument ($arguments -join ' ') -WorkingDirectory $here
56
+ Register-ScheduledTask -TaskName $name -Action $action -Trigger $trigger -Settings $settings -Description $description -Force | Out-Null
57
+ Write-Output "registered $name -> node $($arguments -join ' ')"
58
+ }
59
+
60
+ # Hourly transcript ingest: one task per hour, starting now.
61
+ Register-WriterTask $names.Ingest (Get-ArgList 'ingest.mjs') (New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2) -RepetitionInterval (New-TimeSpan -Hours 1)) 'DSH sessions -> WeKnora session KB (verbatim transcripts)'
62
+
63
+ # Nightly distillation of conclusions into the knowledge KB.
64
+ Register-WriterTask $names.Distill (Get-ArgList 'distill.mjs') (New-ScheduledTaskTrigger -Daily -At $Times.Distill) 'DSH sessions -> WeKnora knowledge KB (distilled conclusions)'
65
+
66
+ # Nightly dream: adjudicate fact cards, then let the profile writer review them.
67
+ $dreamArgs = (Get-ArgList 'dream.mjs') + @('--then-profile')
68
+ Register-WriterTask $names.Dream $dreamArgs (New-ScheduledTaskTrigger -Daily -At $Times.Dream) 'Adjudicate fact cards, then review the standing profile'
69
+
70
+ Write-Output ''
71
+ Write-Output 'Verify with: Get-ScheduledTask | Where-Object TaskName -like "' + $Prefix + '*"'
@@ -0,0 +1,53 @@
1
+ @echo off
2
+ rem Manual one-shot runner for the optional write-side tasks.
3
+ rem run-all.cmd run ingest, distill, dream, profile-writer in order
4
+ rem run-all.cmd --dry-run print the plan only (no DB write, no file write)
5
+ rem run-all.cmd ingest run one step only (ingest | distill | dream | profile)
6
+ setlocal
7
+ cd /d "%~dp0"
8
+
9
+ set "ARGS="
10
+ set "STEP="
11
+ :parse
12
+ if "%~1"=="" goto run
13
+ if /i "%~1"=="ingest" (set "STEP=ingest" & shift & goto parse)
14
+ if /i "%~1"=="distill" (set "STEP=distill" & shift & goto parse)
15
+ if /i "%~1"=="dream" (set "STEP=dream" & shift & goto parse)
16
+ if /i "%~1"=="profile" (set "STEP=profile" & shift & goto parse)
17
+ set "ARGS=%ARGS% %~1"
18
+ shift
19
+ goto parse
20
+
21
+ :run
22
+ set "RC=0"
23
+ if "%STEP%"=="" goto all
24
+ if /i "%STEP%"=="ingest" goto do_ingest
25
+ if /i "%STEP%"=="distill" goto do_distill
26
+ if /i "%STEP%"=="dream" goto do_dream
27
+ if /i "%STEP%"=="profile" goto do_profile
28
+
29
+ :all
30
+ call :do_ingest || set "RC=%ERRORLEVEL%"
31
+ call :do_distill || set "RC=%ERRORLEVEL%"
32
+ call :do_dream || set "RC=%ERRORLEVEL%"
33
+ goto end
34
+
35
+ :do_ingest
36
+ node ingest.mjs%ARGS%
37
+ exit /b %ERRORLEVEL%
38
+
39
+ :do_distill
40
+ node distill.mjs%ARGS%
41
+ exit /b %ERRORLEVEL%
42
+
43
+ :do_dream
44
+ node dream.mjs%ARGS% --then-profile
45
+ exit /b %ERRORLEVEL%
46
+
47
+ :do_profile
48
+ node profile-writer.mjs --review --write%ARGS%
49
+ exit /b %ERRORLEVEL%
50
+
51
+ :end
52
+ if not "%RC%"=="0" echo [run-all] one or more steps failed, rc=%RC%
53
+ exit /b %RC%