dsh-lite-memory 0.11.1 → 0.15.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.
package/learning.js CHANGED
@@ -160,25 +160,13 @@ export function normalizeConditions(conditions) {
160
160
  /** 构造一次受限调用输入:无工具、无前台历史,只有身份、少量规则和反馈映射。 */
161
161
  export function buildCallInput({ projectId, episodeId, taskKind, seriesId, phase, feedback, rules, ranges, maxBytes = LIMITS.inputBytes } = {}) {
162
162
  const refs = { feedback: {}, rules: {} };
163
- const inputRules = [];
164
- let index = 0;
165
- for (const record of Array.isArray(rules) ? rules : []) {
166
- if (!record || !record.id) continue;
167
- if (index >= 6) break;
168
- const ref = `r${index}`;
169
- refs.rules[ref] = record.id;
170
- inputRules.push({
171
- ref,
172
- revision: record.revision || 1,
173
- title: bounded(record.title, 60),
174
- summary: bounded(record.summary, 120),
175
- body: bounded(record.body, 360),
176
- applicability: record.applicability || 'unresolved',
177
- conditions: normalizeConditions(record.conditions),
178
- state: record.learning?.state || record.state || 'trial',
179
- });
180
- index += 1;
181
- }
163
+ const source = (Array.isArray(rules) ? rules : []).filter((record) => record && record.id);
164
+ /** 用户这次纠正涉及的规则先入输入:固定取前 6 条会把真正相关的旧经验挤掉。 */
165
+ const dimension = String(feedback?.cue?.dimension || '');
166
+ const prioritized = dimension
167
+ ? [...source.filter((record) => `${record.title || ''}${record.summary || ''}${record.body || ''}`.includes(dimension)),
168
+ ...source.filter((record) => !`${record.title || ''}${record.summary || ''}${record.body || ''}`.includes(dimension))]
169
+ : source;
182
170
  refs.feedback.u0 = feedback.messageIds[0] || '';
183
171
  const project = {
184
172
  projectId: bounded(projectId, 80),
@@ -193,10 +181,11 @@ export function buildCallInput({ projectId, episodeId, taskKind, seriesId, phase
193
181
  phase: PHASES.slice(),
194
182
  };
195
183
  const system = '你是项目记忆整理器,只输出 JSON。只能依据给定反馈修改已有规则或新建一条项目规则;不得调用工具,不得输出 JSON 之外的文字。没有可确定的长期变化时输出 {"changes":[]}。';
196
- const payload = {
197
- project,
198
- feedback: { refs: [['u0', feedback.text.slice(0, 600)]], type: feedback.cue?.type || 'unclear', longTerm: Boolean(feedback.cue?.explicitLongTerm) },
199
- rules: inputRules,
184
+ const feedbackHead = {
185
+ type: feedback.cue?.type || 'unclear',
186
+ longTerm: Boolean(feedback.cue?.explicitLongTerm),
187
+ };
188
+ const tail = {
200
189
  allowed: { conditions: view.CONDITION_KEYS, ranges: rangesInput, maxChanges: LIMITS.maxChanges, execution_miss: '仅已有规则,currentText 写检查点', artifact_accept: '仅已有规则,记验收样例' },
201
190
  output: {
202
191
  changes: [{
@@ -204,19 +193,49 @@ export function buildCallInput({ projectId, episodeId, taskKind, seriesId, phase
204
193
  }],
205
194
  },
206
195
  };
207
- let prompt = JSON.stringify(payload);
208
- while (Buffer.byteLength(system) + Buffer.byteLength(prompt) > maxBytes && prompt.length > 600) {
209
- prompt = prompt.slice(0, Math.floor(prompt.length * 0.85));
196
+ const render = (selected, feedbackText) => JSON.stringify({
197
+ project,
198
+ feedback: { ...feedbackHead, refs: [['u0', feedbackText]] },
199
+ rules: selected,
200
+ ...tail,
201
+ });
202
+ /** 反馈正文按整段回退,不切开 JSON;仍放不下就是本次调用不可行。 */
203
+ let feedbackText = bounded(feedback.text, 600);
204
+ let prompt = render([], feedbackText);
205
+ while (Buffer.byteLength(system) + Buffer.byteLength(prompt) > maxBytes && feedbackText.length > 0) {
206
+ feedbackText = feedbackText.slice(0, Math.floor(feedbackText.length * 0.6));
207
+ prompt = render([], feedbackText);
208
+ }
209
+ if (Buffer.byteLength(system) + Buffer.byteLength(prompt) > maxBytes) {
210
+ return { system, prompt: '', messages: [], refs, rules: [], bytes: Buffer.byteLength(system), overLimit: true };
211
+ }
212
+ const inputRules = [];
213
+ for (const record of prioritized.slice(0, 6)) {
214
+ const ref = `r${inputRules.length}`;
215
+ refs.rules[ref] = record.id;
216
+ const row = {
217
+ ref,
218
+ revision: record.revision || 1,
219
+ title: bounded(record.title, 60),
220
+ summary: bounded(record.summary, 120),
221
+ body: bounded(record.body, 360),
222
+ applicability: record.applicability || 'unresolved',
223
+ conditions: normalizeConditions(record.conditions),
224
+ state: record.learning?.state || record.state || 'trial',
225
+ };
226
+ const next = render([...inputRules, row], feedbackText);
227
+ if (Buffer.byteLength(system) + Buffer.byteLength(next) > maxBytes) break;
228
+ inputRules.push(row);
229
+ prompt = next;
210
230
  }
211
231
  return { system, prompt, messages: [{ role: 'user', content: prompt }], refs, rules: inputRules, bytes: Buffer.byteLength(system) + Buffer.byteLength(prompt) };
212
232
  }
213
-
214
233
  /** 严格解析:截断、工具调用、额外字段、超限都整批拒绝,不做第二次修复调用。 */
215
234
  export function parseOutput(text, { finish, refs, rules, ranges } = {}) {
216
235
  if (finish && finish.kind !== 'stop' && finish.kind !== 'completed') return { ok: false, reason: '整理没有正常结束' };
217
236
  const raw = String(text || '').trim();
218
237
  if (!raw) return { ok: false, reason: '整理没有返回内容' };
219
- if (raw.length > LIMITS.outputBytes) return { ok: false, reason: '整理输出太长' };
238
+ if (Buffer.byteLength(raw, 'utf8') > LIMITS.outputBytes) return { ok: false, reason: '整理输出太长' };
220
239
  if (/<[^>]+>/.test(raw) && !raw.startsWith('{')) return { ok: false, reason: '整理返回了工具调用' };
221
240
  let data;
222
241
  try {
@@ -388,23 +407,29 @@ export function addCheckpoint(learning, checkpoint = {}) {
388
407
  }
389
408
 
390
409
  /** 状态只看证据强度:明确长期要求或两个独立作品才 active;条件不确定就 pending。 */
391
- export function nextLearningState({ existing, change, evidence, now } = {}) {
410
+ export function nextLearningState({ existing, change, evidence, now, specChanged = false } = {}) {
392
411
  const at = new Date(now || Date.now()).toISOString();
393
412
  const learning = existing?.learning ? JSON.parse(JSON.stringify(existing.learning)) : { state: 'pending', supportedEpisodeIds: [], evidence: [] };
394
413
  const priorState = LEARNING_STATES.includes(learning.state) ? learning.state : 'pending';
395
- const episodes = Array.isArray(learning.supportedEpisodeIds) ? learning.supportedEpisodeIds.slice() : [];
414
+ /** 规范正文或适用条件变了:旧证据不再支持新规范,只保留本次这一条。 */
415
+ const episodes = specChanged ? [] : (Array.isArray(learning.supportedEpisodeIds) ? learning.supportedEpisodeIds.slice() : []);
396
416
  if (evidence?.episodeId && !episodes.includes(evidence.episodeId)) episodes.push(evidence.episodeId);
397
- const anchors = Array.isArray(learning.evidence) ? learning.evidence.slice() : [];
417
+ const anchors = specChanged ? [] : (Array.isArray(learning.evidence) ? learning.evidence.slice() : []);
398
418
  if (evidence?.episodeId && !anchors.some((row) => row.episodeId === evidence.episodeId)) anchors.push(evidence);
399
419
  learning.supportedEpisodeIds = episodes.slice(-LIMITS.evidence);
400
420
  learning.evidence = anchors.slice(-LIMITS.evidence).map((row) => ({ ...row }));
401
421
  learning.updatedAt = at;
402
422
  learning.lastFeedbackType = change?.feedbackType || learning.lastFeedbackType || '';
403
423
  if (change?.currentText) learning.lastText = change.currentText;
404
- if (priorState === 'paused') {
424
+ if (priorState === 'paused' && !specChanged && !Boolean(evidence?.explicitLongTerm)) {
405
425
  learning.state = 'paused';
406
426
  return learning;
407
427
  }
428
+ if (priorState === 'paused') {
429
+ delete learning.blockedReason;
430
+ delete learning.pausedAt;
431
+ delete learning.pausedBy;
432
+ }
408
433
  const conditions = normalizeConditions(change?.conditions !== undefined ? change.conditions : existing?.conditions);
409
434
  const determinate = (change?.applicability || existing?.applicability) === 'conditional' && conditions.length > 0;
410
435
  const explicit = Boolean(evidence?.explicitLongTerm);
@@ -425,6 +450,7 @@ export function nextLearningState({ existing, change, evidence, now } = {}) {
425
450
  })) learning.state = 'trial';
426
451
  else learning.state = 'pending';
427
452
  if (learning.state === 'trial') learning.trialUntil = new Date((now || Date.now()) + LIMITS.trialMs).toISOString();
453
+ else delete learning.trialUntil;
428
454
  if (learning.state === 'active') {
429
455
  learning.acceptedAt = learning.acceptedAt || at;
430
456
  delete learning.trialUntil;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.11.1",
3
+ "version": "0.15.0",
4
4
  "description": "On-demand memory for the DeepSeek Harness: nothing in the system prompt, one muted line only when something is saved, hit or suggested.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,7 +8,7 @@
8
8
  "dsh": ">=0.1.5"
9
9
  },
10
10
  "scripts": {
11
- "test": "node test/store.test.mjs && node test/state.test.mjs && node test/view.test.mjs && node test/anchor.test.mjs && node test/recall.test.mjs && node test/evolution-read.test.mjs && node test/learning.test.mjs && node test/scenario.test.mjs && node test/crossing.test.mjs && node test/client.test.mjs && node test/contract.test.mjs",
11
+ "test": "node test/store.test.mjs && node test/state.test.mjs && node test/view.test.mjs && node test/anchor.test.mjs && node test/recall.test.mjs && node test/evolution-read.test.mjs && node test/lexical.test.mjs && node test/control.test.mjs && node test/learning.test.mjs && node test/scenario.test.mjs && node test/crossing.test.mjs && node test/client.test.mjs && node test/contract.test.mjs",
12
12
  "bench": "node bench/bench.mjs",
13
13
  "bench:evolution": "node bench/evolution.mjs"
14
14
  },
package/state.js CHANGED
@@ -99,7 +99,51 @@ export function evolutionOf(state) {
99
99
  };
100
100
  }
101
101
 
102
- /** 开关事务里的纯状态部分:旋转 epoch、推进反馈代次;关闭时把试用条目转暂停待复核。 */
102
+ /**
103
+ * 项目记忆总开关:缺字段按开启处理,老状态不用迁移。
104
+ * 它管这一层的所有读取、写入、召回和学习;全局层仍按发起项目判断,不跟着关。
105
+ */
106
+ export function controlOf(state) {
107
+ const raw = state && state.control;
108
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
109
+ return { enabled: true, epoch: '', changedAt: '', compatible: true };
110
+ }
111
+ return {
112
+ enabled: raw.enabled !== false,
113
+ epoch: typeof raw.epoch === 'string' ? raw.epoch : '',
114
+ changedAt: typeof raw.changedAt === 'string' ? raw.changedAt : '',
115
+ compatible: true,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * 关总开关只是收回授权:候选、试用、生效规则、证据、默认绑定和已用额度都保留,
121
+ * 只清运行层(在途整理、临时锚点、旧召回快照)。重新打开也换代次,关闭前排队的任务不能补交。
122
+ * 自进化跟随总开关一起停:重新打开记忆后,自进化仍是关闭的,等用户单独开启。
123
+ */
124
+ export function applyControl(draft, enabled) {
125
+ const now = new Date().toISOString();
126
+ const current = controlOf(draft);
127
+ const next = Boolean(enabled);
128
+ if (current.enabled === next) return { changed: false, enabled: next };
129
+ const config = { enabled: next, epoch: crypto.randomUUID(), changedAt: now };
130
+ draft.control = config;
131
+ const evolution = draft.experimental && draft.experimental.projectEvolution;
132
+ if (evolution && typeof evolution === 'object' && !Array.isArray(evolution)) {
133
+ const ledger = normalizeLearning(evolution.learning);
134
+ evolution.enabled = false;
135
+ evolution.epoch = crypto.randomUUID();
136
+ evolution.feedbackGeneration = Number.isInteger(evolution.feedbackGeneration) ? evolution.feedbackGeneration + 1 : 1;
137
+ evolution.changedAt = now;
138
+ /** 默认绑定属于已确认结果;临时锚点和在途观察不属于。 */
139
+ evolution.tasks = { ...emptyTasks(), ...(evolution.tasks && evolution.tasks.defaults ? { defaults: evolution.tasks.defaults } : {}) };
140
+ /** 额度只增不减:次数与回执留着,最近观察清掉。 */
141
+ evolution.learning = { ...ledger, recent: [] };
142
+ }
143
+ return { changed: true, enabled: next, epoch: config.epoch };
144
+ }
145
+
146
+ /** 自进化开关只停运行层:规则、候选、试用、证据和额度都保留,恢复时接着用。 */
103
147
  export function applyEvolution(draft, enabled) {
104
148
  const now = new Date().toISOString();
105
149
  const current = evolutionOf(draft);
@@ -114,10 +158,11 @@ export function applyEvolution(draft, enabled) {
114
158
  changedAt: now,
115
159
  /** 开与关都让上一轮锚点失效;已经确认的项目默认保留,它不属于临时作品状态。 */
116
160
  tasks: { ...emptyTasks(), ...(current.tasks && current.tasks.defaults ? { defaults: current.tasks.defaults } : {}) },
117
- /** 关开不继承在途整理与旧提案;已学规则的来源与版本仍留在条目里。 */
118
- learning: emptyLearning(),
161
+ /** 只清在途观察;已学规则的来源、版本和候选都在条目里,关开不动它们。 */
162
+ learning: { ...normalizeLearning(current.learning), recent: [] },
119
163
  };
120
164
  draft.experimental = { ...(draft.experimental || {}), projectEvolution: config };
165
+ /** 显式关闭自进化:试用转暂停待复核,不删除候选与证据;重新打开后可逐条恢复。 */
121
166
  if (!next) {
122
167
  for (const entry of draft.entries) {
123
168
  const learning = entry && entry.maintenance && entry.maintenance.learning;
@@ -128,9 +173,6 @@ export function applyEvolution(draft, enabled) {
128
173
  learning.pausedBy = 'experiment_off';
129
174
  }
130
175
  }
131
- /** 未提交的候选随关闭清掉,不保留一份看不见却还能被旧提案命中的正文。 */
132
- draft.entries = draft.entries.filter((entry) => !(entry && entry.maintenance && entry.maintenance.learning && entry.maintenance.learning.state === 'pending'));
133
- draft.candidates = draft.candidates.filter((row) => !row || row.origin !== 'project_evolution');
134
176
  }
135
177
  return { changed: true, enabled: next, epoch: config.epoch };
136
178
  }
@@ -140,7 +182,7 @@ export const LIMITS = {
140
182
  entries: 200,
141
183
  globalEntries: 200,
142
184
  hardEntries: 1000,
143
- entryBytes: 4096,
185
+ entryBytes: 1200,
144
186
  recordBytes: 10 * 1024,
145
187
  totalBytes: 512 * 1024,
146
188
  historyBytes: 512 * 1024,
@@ -234,7 +276,10 @@ export function resolveRoot(cwd, options = {}) {
234
276
  const explicit = options.projectRoot ? realpathOf(path.resolve(options.projectRoot)) : '';
235
277
  if (explicit) return { root: explicit, source: 'configured', confirmed: true };
236
278
  const configured = configuredRoot();
237
- if (configured) return { root: configured, source: 'configured', confirmed: true };
279
+ /** 覆盖根只对它所在的子树生效:别的目录不能被一个全局配置并成同一个项目。 */
280
+ if (configured && (requested === configured || requested.startsWith(`${configured}${path.sep}`))) {
281
+ return { root: configured, source: 'configured', confirmed: true };
282
+ }
238
283
  const cacheKey = `${options.workspaceDir || ''}\n${requested}`;
239
284
  if (rootCache.has(cacheKey)) return rootCache.get(cacheKey);
240
285
  let resolved;
@@ -272,6 +317,53 @@ export function globalScope() {
272
317
  return { key: 'global', dir: path.join(memoryRoot(), 'global'), cwd: '', rootSource: 'global', confirmed: true };
273
318
  }
274
319
 
320
+ /**
321
+ * 管理面枚举:只读已有 state.json 的项目层,不迁移旧格式、不创建目录。
322
+ * 损坏或读不出的层跳过;列表按更新时间倒序,limit 防一次扫太多。
323
+ */
324
+ export function knownProjectScopes(limit = 40) {
325
+ const root = memoryRoot();
326
+ let names;
327
+ try {
328
+ names = fs.readdirSync(root, { withFileTypes: true });
329
+ } catch {
330
+ return [];
331
+ }
332
+ const rows = [];
333
+ for (const item of names) {
334
+ if (!item.isDirectory() || item.name === 'global' || item.name === 'sessions' || item.name.startsWith('.')) continue;
335
+ const dir = path.join(root, item.name);
336
+ const file = path.join(dir, 'state.json');
337
+ try {
338
+ if (!fs.statSync(file).isFile()) continue;
339
+ } catch {
340
+ continue;
341
+ }
342
+ let state;
343
+ try {
344
+ state = readEnvelope(file);
345
+ } catch {
346
+ continue;
347
+ }
348
+ if (!state) continue;
349
+ const entries = Array.isArray(state.entries) ? state.entries : [];
350
+ const active = entries.filter((entry) => entry.status !== 'archived');
351
+ const evolution = state.experimental && state.experimental.projectEvolution ? state.experimental.projectEvolution : {};
352
+ rows.push({
353
+ key: item.name,
354
+ dir,
355
+ cwd: typeof state.cwd === 'string' ? state.cwd : '',
356
+ updatedAt: state.updatedAt || '',
357
+ entries: active.length,
358
+ archived: entries.length - active.length,
359
+ experimental: active.filter((entry) => entry.origin === 'project_evolution' || Boolean(entry.learning)).length,
360
+ enabled: evolution.enabled === true,
361
+ });
362
+ }
363
+ rows.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
364
+ return rows.slice(0, Math.max(1, limit));
365
+ }
366
+
275
367
  const stateFile = (scope) => path.join(scope.dir, 'state.json');
276
368
  const prevFile = (scope) => path.join(scope.dir, 'state.prev.json');
277
369
  const lockFile = (scope) => path.join(scope.dir, '.lock');
@@ -370,6 +462,7 @@ export function emptyState(scope) {
370
462
  candidates: [],
371
463
  receipts: [],
372
464
  suppressions: [],
465
+ control: { enabled: true, epoch: crypto.randomUUID(), changedAt: now },
373
466
  experimental: { projectEvolution: newEvolution() },
374
467
  };
375
468
  }
@@ -1350,6 +1443,12 @@ export function purge(scope, options = {}) {
1350
1443
  at: now,
1351
1444
  }];
1352
1445
  draft.epoch = intent.toEpoch;
1446
+ /** 彻底删除也要作废本轮实验凭证:旧 contextId / 在途整理不能把刚删的规则带回来。 */
1447
+ const evolution = draft.experimental && draft.experimental.projectEvolution;
1448
+ if (evolution && evolution.enabled === true) {
1449
+ evolution.epoch = crypto.randomUUID();
1450
+ evolution.feedbackGeneration = (Number(evolution.feedbackGeneration) || 0) + 1;
1451
+ }
1353
1452
  draft.generation = loaded.state.generation + 1;
1354
1453
  draft.updatedAt = now;
1355
1454
  validateState(draft);