@nightpainters/dsh-ling 1.6.0 → 1.6.2

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.
@@ -12,7 +12,10 @@
12
12
  // "没有 UA"正是那条路的指纹(详见下面 isLocalSurgeryRequest 的注释)。**不许在本文件里另写一份 IP 判断。**
13
13
  // 1.2 每启动票据 = $DSH_HOME/cache/dsh-ling/surgery.ticket(32 字节随机),只对**本机直连**(无代理头且带 UA)的请求下发,
14
14
  // 每启动轮换(旧票即废);客户端首次手术前取一次并缓存,随后带 `x-dsh-ling-surgery: <ticket>`。
15
- // 1.3 门下沉为一处 requireSurgery(headers, body, opts),覆盖全部 12 条人格/规矩/习惯写面。
15
+ // 1.3 门下沉为一处 requireSurgery(headers, body, opts),覆盖全部 **14** 条人格/规矩/习惯写面。
16
+ // ⚠️ 条数在 2026-10-05 夜由 12 订正为 14(B-5):下方的覆盖面清单**逐条列了 14 行**,而本行与
17
+ // `api.js` 的门下沉注释一直写"12" ⇒ 两处都在说谎(清单才是逐条列出的权威)。
18
+ // 口径:**条数一律以清单行数为准**,改清单时必须同批改这里与 api.js 那一处。
16
19
  // 1.4 事件留痕 = $DSH_HOME/logs/surgery-YYYY-MM.jsonl,append-only + 整行哈希链(改一行即断链)。
17
20
  // 1.5 逃生开关:DSH_LING_GUARD=off 或 settings.guard.enforce === false ⇒ 整体放行(绝不把主人锁在门外)。
18
21
  //
package/lib/host/tools.js CHANGED
@@ -33,7 +33,7 @@ import { listSessionFiles, readSessionEvents, dialogTurns, localStamp, oneLine,
33
33
  import { userTitleForMode } from './persona.js';
34
34
  // 1.6 §7 步 1(2026-10-04 主人拍 D1-D6 后开工):recall 的**检索档** —— 按内容找会话,接宿主检索面(不自建)。
35
35
  // 为什么不自建、中文怎么查、范围怎么限:见 recall-search.js 顶部注释(含本机实测口径)。
36
- import { clampSearchLimit, visibleIdSet, toSearchRows, renderSearch, searchDeepItems, adoptDeepItems } from './recall-search.js';
36
+ import { clampSearchLimit, visibleIdSet, toSearchRows, renderSearch, searchDeepItems, searchLocalTurns, adoptDeepItems, withTimeout, SEARCH_TIMEOUT_MS } from './recall-search.js';
37
37
 
38
38
  export const TOOL_RULE_ADD = 'rule_add';
39
39
  export const TOOL_HABIT_PROPOSE = 'habit_propose';
@@ -505,8 +505,8 @@ function renderBranchEdit(v) {
505
505
  return `把${label(v.id, v.idName)}的枝系数设为 ${got}。`
506
506
  + (clamped ? `\n· 你要的是 ${asked}:超出有效区间 [0, 2],库里存的是夹取后的 ${got}。` : '')
507
507
  + (v.applied === false
508
- ? `\n· **它当前不参与检索打分**(${v.note || 'weight_scale 是预留字段'})—— 调了不会改变召回排序。`
509
- : '')
508
+ ? `\n· **它当前不参与检索打分**(${v.note || '预留字段'})—— 调了不会改变召回排序。`
509
+ : `\n· **它会真的改变召回排序**(${v.note || '已生效'})—— 调低某条枝,从这条枝的会话里提炼出的结论在记忆开场里往后排(0 = 排到最后,**不是禁用**)。`)
510
510
  + TALLY_LINE;
511
511
  }
512
512
  const what = {
@@ -560,7 +560,7 @@ export const BRANCH_EDIT_SPEC = {
560
560
  + "不默认挂主干;要挂回主干请显式传 'trunk';内容零变化)/ link(连边,需 id+to,"
561
561
  + '可选 kind 说明关系:related 默认 / prerequisite 前置 / contrast 对照 / example 例证 / supersede 取代)/ '
562
562
  + 'unlink(断边,需 id+to)/ weight(调枝系数,需 id+weightScale,复盘时用;'
563
- + '有效区间 [0,2],超出会被夹取,且**该字段当前不参与检索打分**)/ '
563
+ + '有效区间 [0,2],超出会被夹取;**该字段参与检索打分**(1.6.2 起,见 DESIGN.md 的血缘加权那一节))/ '
564
564
  + 'delete(删枝,需 id;非空枝要 force —— force 会把它的子枝与会话归属**回退到主干**,'
565
565
  + '原归属不可还原)。所有改动都记在「鱼姬」名下,'
566
566
  + '并出现在他下一次开口前的注入里。',
@@ -812,7 +812,7 @@ export function checkRecall(args = {}) {
812
812
  // ①档(检索):`q` 与 `conv` **互斥** —— 两个一起给会让"找会话"与"读某个会话"两种意图打架。
813
813
  // 直接拒绝并让回执说清楚,不猜(D-T07 同一条纪律:宁可让调用方再调一次)。
814
814
  if (q && conv) return { ok: false, reason: 'q-xor-conv' };
815
- if (q) return { ok: true, action: 'search', q, all: args?.all === true, limit: clampSearchLimit(args?.limit) };
815
+ if (q) return { ok: true, action: 'search', q, all: args?.all === true, hostSearch: args?.hostSearch === true, includeSelf: args?.includeSelf === true, limit: clampSearchLimit(args?.limit) };
816
816
  if (!conv && hasRange) return { ok: false, reason: 'conv-required' };
817
817
  if (!conv) {
818
818
  const n = Math.floor(Number(args?.limit));
@@ -925,9 +925,14 @@ export const RECALL_SPEC = {
925
925
  + '②不给 conv 也不给 q ⇒ 列出磁盘上有原文的会话(最近活动在前);'
926
926
  + '③给 conv ⇒ 该会话的**对话目录**(每轮一行:序号/时间/角色/前 70 字),**不给正文**;'
927
927
  + '④给 conv+from/to ⇒ 那几轮的**原文全文**(序号取自目录里的 #号)。'
928
+ + '①档**默认搜本机库内原文层**(毫秒级;只含真人消息与有正文的回复 ⇒ 我自己的长回复**中段搜不到**),'
929
+ + '要全量(覆盖全部事件、含子代理会话)就传 hostSearch=true 走宿主(慢:实测每次 >20 秒,超时会**如实说明**而不是假装没找到)。'
928
930
  + '检索口径(本机实测,别猜):中文 **2~4 字短词**命中最好(`记忆树` / `血缘` / `注入面`);'
929
931
  + '**多个词用空格分隔 = 同时满足**(AND);**没有子串匹配**(`call` 搜不到 `recall`)。'
930
932
  + '默认**只搜「主人自己的会话」**(顶层 + 分叉);要看全部(含子代理)得显式 all=true。'
933
+ + '**条目层先给、原文层限时**:深层库条目是本地库(毫秒级),**永远先返回**;会话原文走宿主索引,'
934
+ + `超过 ${Math.round(SEARCH_TIMEOUT_MS / 1000)} 秒没返回就放弃那一半、并如实说明 —— 免得一次检索把整步卡死(那是无法中断的等待)。`
935
+ + '**当前会话自己**如果命中,会标注「← 本会话(刚发生的)」并排在最后 —— 别把它当"当年说过的话"引用。'
931
936
  + '**工具不会替你挑"最重要的一段"** —— 先看命中片段或目录,再自己指哪几轮。'
932
937
  + '只返回**对话**(主人对我说的话 + 我的回复);工具调用/步骤/折叠等过程事件已剥离;'
933
938
  + '注入类消息(运行时上下文、技能目录、任务标记)在目录里**折叠成一行、不占序号**。',
@@ -935,11 +940,13 @@ export const RECALL_SPEC = {
935
940
  type: 'object',
936
941
  properties: {
937
942
  q: { type: 'string', description: '①档:按内容找会话的查询词。中文 2~4 字最好;多词用空格分隔(= 同时满足);没有子串匹配。与 conv 互斥。' },
938
- all: { type: 'boolean', description: '①档:是否越界到非主人的会话(子代理等)。默认 false = 只搜主人自己的会话。' },
943
+ all: { type: 'boolean', description: '①档:是否越界到非主人的会话(子代理等)。默认 false = 只搜主人自己的会话。**只在 hostSearch=true 时有意义**。' },
944
+ hostSearch: { type: 'boolean', description: '①档:**是否走宿主全文检索面**(默认 false)。默认搜**本机库内原文层** —— 毫秒级,但只含真人消息与有正文的回复(我自己的长回复中段搜不到);传 true 则走宿主(覆盖全部事件,但实测每次 >20 秒,超时会如实告知)。' },
945
+ includeSelf: { type: 'boolean', description: '①档:**是否包含当前会话自己**(默认 false = 排除)。默认排除是因为"我刚说过什么"就在我的上下文里、不需要搜,而它的话最新 ⇒ 任何词都能命中它、把要回溯的历史挤掉。要查自己刚说的那句才传 true(那时它排到最后并标注)。**只在默认(本地)路生效**。' },
939
946
  conv: { type: 'string', description: '③④档:会话 id(取自 ①/② 档的清单);也接受裸 uuid' },
940
947
  from: { type: 'number', description: '④档:起始轮次序号(1-based,**取自目录里的 #号**);缺省 1' },
941
948
  to: { type: 'number', description: '④档:结束轮次序号(**含**);缺省 from+9' },
942
- limit: { type: 'number', description: '①档一页几个会话(默认 10,上限 50)/ ②档列多少个会话(默认 40,上限 200)' },
949
+ limit: { type: 'number', description: '①档一页几个会话(默认 5,上限 50 — 默认值刻意小:原文层一多,上面的条目层就被挤没了)/ ②档列多少个会话(默认 40,上限 200)' },
943
950
  },
944
951
  required: [],
945
952
  },
@@ -1262,7 +1269,22 @@ export function registerLingTools(ctx, { gate, memory, settings } = {}) {
1262
1269
  // 纯逻辑全在 recall-search.js(可单测),这里只做接线与降级。
1263
1270
  // 降级口径很重要:服务不在 ⇒ 明说"**通道没接上**",而不是让模型读成"库里没有"——
1264
1271
  // 这两件事该采取的动作完全不同(前者换路,后者换词),混在一起就是骗自己。
1265
- const runSearch = async (q, { all, limit, callName }) => {
1272
+ const runSearch = async (q, { all, limit, callName, selfId = '', hostSearch = false, includeSelf = false }) => {
1273
+ // ⚠️ **本地优先**(2026-10-05 夜 · 主人拍板方案 A):条目层与原文层都在**我们自己的 SQLite** 里,
1274
+ // 实测同一个查询 **2~45 毫秒**;而宿主 `sessionQuery` 每次搜索都要做一遍全量对账,
1275
+ // 实测**每次都超过 20 秒**(重启后第一次也一样)⇒ 快的设成默认、慢的降级成显式选项。
1276
+ // 代价是覆盖窄(库内原文只含"真人消息 + 有正文的回复")⇒ 回执里如实标注,见 `layerNote`。
1277
+ const deep = searchDeepItems(memory, q);
1278
+ if (!hostSearch) {
1279
+ // ⚠️ 本会话**默认整条排除**(主人 2026-10-05 夜拍板):它的话最新 ⇒ 任何词都能命中它,
1280
+ // 把要回溯的历史整页挤掉(真机实测「检索」10 条里 9 条是本会话)。要看自己刚说的传 `includeSelf:true`。
1281
+ const loc = searchLocalTurns(memory, q, limit, { selfId, callName, includeSelf });
1282
+ return {
1283
+ ok: true, action: 'search', q, all, rows: loc.rows, skipped: 0, deep, degraded: false,
1284
+ local: true, hasMore: loc.rows.length >= limit, timedOut: false, selfId,
1285
+ selfExcluded: loc.selfExcluded,
1286
+ };
1287
+ }
1266
1288
  let svc = null;
1267
1289
  try {
1268
1290
  svc = typeof ctx?.get === 'function' ? ctx.get('sessionQuery') : (ctx?.sessionQuery ?? null);
@@ -1276,9 +1298,12 @@ export function registerLingTools(ctx, { gate, memory, settings } = {}) {
1276
1298
  try {
1277
1299
  let visible = null;
1278
1300
  if (!all && typeof svc.listSessions === 'function') {
1279
- visible = visibleIdSet(await svc.listSessions());
1301
+ // `listSessions` 同样套超时:它也要过索引(实测同一条通道会一起卡住)。
1302
+ const lv = await withTimeout(svc.listSessions(), SEARCH_TIMEOUT_MS, 'listSessions');
1303
+ if (lv.ok) visible = visibleIdSet(lv.value);
1304
+ else return { ok: true, action: 'search', q, all, rows: [], skipped: 0, deep, degraded: true, hasMore: false, timedOut: true, timeoutMs: lv.ms, selfId };
1280
1305
  }
1281
- const page = await svc.searchSessions({
1306
+ const res = await withTimeout(svc.searchSessions({
1282
1307
  query: q,
1283
1308
  limit,
1284
1309
  // 与官方服务端搜索同一套过滤(dsh-api-session-controller\lib\index.js:1953):
@@ -1287,24 +1312,33 @@ export function registerLingTools(ctx, { gate, memory, settings } = {}) {
1287
1312
  { kind: 'type', values: ['user/message', 'assistant/message'] },
1288
1313
  { kind: 'surface', values: ['current'] },
1289
1314
  ],
1290
- });
1291
- const { rows, skipped, degraded } = toSearchRows(page?.items, { visible, all, callName });
1315
+ }), SEARCH_TIMEOUT_MS, 'searchSessions');
1316
+ // 超时 ⇒ **不是失败**:条目层照常交出去,只把"原文那半没跑完"如实标出来。
1317
+ if (!res.ok) {
1318
+ return { ok: true, action: 'search', q, all, rows: [], skipped: 0, deep, degraded: false, hasMore: false, timedOut: true, timeoutMs: res.ms, selfId };
1319
+ }
1320
+ const page = res.value;
1321
+ const { rows, skipped, degraded } = toSearchRows(page?.items, { visible, all, callName, selfId });
1292
1322
  // 1.6 §7 步 4(2026-10-04):**同一次检索也查深层库条目** —— 主人的问句多半是
1293
1323
  // 「我说过什么关于 X 的」,而答案更可能在**结论层**(deep_item)而不是某段原始对话里。
1294
1324
  // 两者一起给,「找条目」与「找会话」一次到位。条目不受 `all` 影响(深层库本来就是
1295
1325
  // 从主人自己的会话里提炼的;它的范围控制在提炼那一侧,不在这里)。
1296
- const deep = searchDeepItems(memory, q);
1297
- return { ok: true, action: 'search', q, all, rows, skipped, deep, degraded, hasMore: Boolean(page?.nextCursor) };
1326
+ return { ok: true, action: 'search', q, all, rows, skipped, deep, degraded, hasMore: Boolean(page?.nextCursor), timedOut: false, selfId };
1298
1327
  } catch (e) {
1299
1328
  return { ok: false, reason: 'search-failed', message: String(e?.message ?? e).slice(0, 200) };
1300
1329
  }
1301
1330
  };
1302
1331
  const recallSpec = {
1303
1332
  ...RECALL_SPEC,
1304
- execute: guard(async (args) => {
1333
+ // ⚠️ handler **必须收第二个参数 `exec`**(2026-10-05 实测抓到):`guard` 的签名是
1334
+ // `(fn) => async (args, exec) => …`;只写 `(args) => …` 而体内用 `sessionIdOf(exec)` 时,
1335
+ // `exec` 是**未定义变量** ⇒ 严格模式下抛 ReferenceError ⇒ 「按内容找会话」整条路直接失败。
1336
+ // 教训:凡在 handler 里用到 `exec`,先回来看这一行有没有接住它 —— 这类错**只在真机跑才现形**,
1337
+ // 44 套测试当时全绿(它们测的是纯函数 `checkRecall`,不经过这个 handler)。
1338
+ execute: guard(async (args, exec) => {
1305
1339
  const c = checkRecall(args);
1306
1340
  if (!c.ok) return c;
1307
- if (c.action === 'search') return runSearch(c.q, { all: c.all, limit: c.limit, callName: recallCallName });
1341
+ if (c.action === 'search') return runSearch(c.q, { all: c.all, limit: c.limit, callName: recallCallName, selfId: sessionIdOf(exec), hostSearch: c.hostSearch, includeSelf: c.includeSelf });
1308
1342
  if (c.action === 'map') {
1309
1343
  const r = listSessionFiles({ limit: c.limit });
1310
1344
  if (!r.ok) return { ok: false, reason: r.reason, message: r.message };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nightpainters/dsh-ling",
3
- "version": "1.6.0",
4
- "description": "器灵 · 跑在你自己电脑上的记忆与人格助手插件:三层记忆、从历史里长出来的人格档案、定型锁与自由生长",
3
+ "version": "1.6.2",
4
+ "description": "器灵 · 跑在你自己电脑上的记忆与人格助手插件:以「人在循环」的双向复盘为核心回路,人格由共同记忆长出并有定型锁,记忆树可归并、可回溯、每次改动都留痕",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "exports": {
@@ -14,6 +14,7 @@
14
14
  "tools/apply-access-log-patch.mjs",
15
15
  "cordis.patch.yml",
16
16
  "README.md",
17
+ "PHILOSOPHY.md",
17
18
  "CHANGELOG.md",
18
19
  "DESIGN.md",
19
20
  "ACCESS-DESIGN.md",