@furongjun1999/dsh-memory 0.7.3 → 0.7.4

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/md_cg/tokens.py CHANGED
@@ -97,7 +97,14 @@ ALL_OPS = ("help", "info", "route", "read", "write", "goal", "task", "recent", "
97
97
  # 缺省不给(fail-closed,新增 op 默认不在任何清单内);
98
98
  # 本轮开给:designer(* 自动含)+ verify(证据审计=验证
99
99
  # 单元的本职读面,见其 ROLE_SPECS 注释)。
100
- "audit")
100
+ # P3 新增(世界模型功能端 P3,2026-10-06):
101
+ # state_event 状态事件记账(追加一条五元事件到 append-only 台账)
102
+ # —— 写口;**角色面零新词**:cg 面作用域闸把它映射到既有
103
+ # "write" 词(见 mcp_server._cg_dispatch),各角色
104
+ # ROLE_SPECS.ops_allow 白名单零改动;登记于本表只为
105
+ # 「op 三处同步」契约(ALL_OPS == cg 工具 schema == 分发分支,
106
+ # 见 test_p27/test_p29/test_p30/test_p31 防漏改守卫)。
107
+ "audit", "state_event")
101
108
 
102
109
 
103
110
  class TokenError(Exception):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@furongjun1999/dsh-memory",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "灵枢(Lingshu·líng shū)DeepSeek Harness 插件:完整大脑——长期记忆/知识飞轮/自我认知/递归反思接入 DSH,对话自动沉淀进 md_cg 认知图(md 文档)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "lingshu-skills",
4
- "version": "0.7.3",
4
+ "version": "0.7.4",
5
5
  "description": "灵枢(AEIS)自我认知技能包——白箱条件化知识(Agent Skills 兼容导出)。本质是灵枢了解自身的工具:每个技能描述灵枢在什么条件下能做什么、怎么执行、克制什么(KCCS 四要素),由条件路由图精确路由,由灵枢 MCP 工具执行验证(物理基底)。",
6
6
  "author": {
7
7
  "name": "灵枢(AEIS)· CommonTrustProtocol"
package/src/hooks.ts CHANGED
@@ -51,6 +51,9 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
51
51
  import type { MdcgClient } from './lib/mdcg_client.js'
52
52
  import { escapePromptBraces, renderUntrustedMemoryBlock } from './lib/prompt_safety.js'
53
53
  import { HookAuditRecorder, kindOf, type HookWriteRole } from './lib/hook_audit.js'
54
+ // 运行期会话状态单点(B 治本批):观测在 hooks 面,写归因注入在工具面
55
+ // (src/tools.ts)——两处共用同一状态,见 lib/session_state.ts 头注。
56
+ import { currentSession, noteSession, UNASSIGNED_SESSION } from './lib/session_state.js'
54
57
 
55
58
  /** 自动记忆开关。 */
56
59
  export interface MemoryHooksOptions {
@@ -334,16 +337,8 @@ function sessionIdOf(raw: unknown): string {
334
337
  return typeof v === 'string' ? v.trim() : ''
335
338
  }
336
339
 
337
- /** 会话归属未知时的**显式占位**(H2③,2026-09-30)。
338
- *
339
- * ⚠️ 不可退回「不传 session 键」:md_cg 的 `Principal.__init__` 在 session 为假值时
340
- * 生成**进程级随机** `sess_<hex>`(md_cg/security.py:117)——插件不传,等于让一个
341
- * 进程内所有「宿主未给标识」的会话共用一个**不可辨认**的随机桶:归属在审计上既
342
- * 读不出是谁、跨进程也对不上,是静默的归属丢失。
343
- * 本常量把这一态写成**显式值**:跨进程一致、可辨认、可审计,且不是伪造的宿主
344
- * 会话 id(非 DSH 形态,服务端 `_normalize_session` 原样采用、不会被改写成别的桶)。
345
- * 要读这个桶:`stg(op=timeline, session="unassigned")`。 */
346
- const UNASSIGNED_SESSION = 'unassigned'
340
+ // UNASSIGNED_SESSION(会话归属未知时的显式占位常量)单点已移至
341
+ // lib/session_state.ts(B 治本批:本文件与工具面共用同一常量与同一会话状态)。
347
342
 
348
343
  /** H1 **会话级**判据:这条 session 是否「子代理/委派子会话」。
349
344
  *
@@ -415,8 +410,24 @@ export function installMemoryHooks(ctx: Context, mdcg: MdcgClient | null, opts:
415
410
  * 绝不冒泡进记忆路径、不改任何写入/过滤判定。 */
416
411
  const audit = new HookAuditRecorder(opts.auditPath)
417
412
 
418
- /** 最近一次观测到的宿主会话标识(见 sessionIdOf;空串 = 未知/无会话)。 */
419
- let lastSession = ''
413
+ /** 本实例是否曾观测到会话(B 治本批)。
414
+ *
415
+ * 会话状态本体是**进程级单点**(lib/session_state.ts;hooks 面观测、工具面
416
+ * src/tools.ts 的写归因注入共用);而「本实例有没有观测过」是**实例级**事实:
417
+ * 多实例并存时(测试;或宿主重装钩子),不得让**别的实例**的观测替本实例决定
418
+ * 召回过滤——否则新装的钩子在尚未观测到会话时就按别的实例的会话去读
419
+ * (读错会话,正是 P45 会话隔离要防的形态)。真机单实例下两者等价:首个
420
+ * session/event 之前模块状态为空,之后回落值恒为同一单点值。
421
+ *
422
+ * ⚠️ 这是**经 owner 裁定的契约字面偏离(dwfq-7b3a555e-1)**:契约给的形态是
423
+ * 下方回落处直接 `|| currentSession()`(无本门);但字面形态与硬边界
424
+ * 「test/session-attribution.test.ts 逐字未动且全绿」互斥——该守卫 ② 以
425
+ * 「新建 harness = 未观测」为前提,字面回落会读成前一实例的 sess_B。裁定
426
+ * 接受本门,两条理由:① 保留原闭包变量「新实例 = 干净状态」的**有意**语义
427
+ * (新装钩子未观测时不加召回过滤);② 冻结守卫零误伤。真机单实例与字面
428
+ * **逐位等价**(差异窗口「本实例未观测 ∧ 模块单点非空」单实例下不可达;
429
+ * HMR 重载时新实例回落 '' 属更保守行为)。 */
430
+ let observedSession = false
420
431
 
421
432
  /** 记忆沉淀(fire-and-forget)。认知图未就绪则跳过并告警(不退回 AEIS)。
422
433
  * `role`=null 表示**读预热**(user-recall)——审计只统计写入路径,
@@ -471,7 +482,13 @@ export function installMemoryHooks(ctx: Context, mdcg: MdcgClient | null, opts:
471
482
  // 想读**所有**会话做了什么:别走自动召回(它会串台),显式调
472
483
  // `stg(op=timeline, session="*")`,返回项带 session 归属。
473
484
  const hostCtx = (_ctx as unknown) as { agent?: { session?: unknown } } | undefined
474
- const sid = sessionIdOf(hostCtx?.agent?.session) || lastSession
485
+ // 回落 = **本实例观测门 × 模块单点值**(裁定项 dwfq-7b3a555e-1,见上方
486
+ // observedSession 注释):本实例尚未观测 → 回落 ''(保持「新实例 =
487
+ // 干净状态」,与 test/session-attribution.test.ts ② 的「未观测 → 不加
488
+ // 过滤」相容);已观测 → 取 lib/session_state.ts 的进程级单点值。
489
+ // 真机单实例下两者逐位等价(差异窗口不可达)。
490
+ const sid = sessionIdOf(hostCtx?.agent?.session)
491
+ || (observedSession ? currentSession() : '')
475
492
  const text = formatTimelineDecayed(
476
493
  await graph.timeline(recallLimit, sid ? { session: sid } : {}))
477
494
  if (text) {
@@ -499,8 +516,9 @@ export function installMemoryHooks(ctx: Context, mdcg: MdcgClient | null, opts:
499
516
 
500
517
  ctx.on('session/event', (session, event: SessionEvent) => {
501
518
  // H1(2026-09-30)**会话级**判据:子代理/委派子会话的自动记忆**整条会话**拦掉。
502
- // 位置在取 sid **之前**——子代理会话不得污染 lastSession,否则顶层会话的自动
503
- // 召回会拿子代理的 session 去读(读错会话)。字段缺失即不拦,见 isSubagentSession。
519
+ // 位置在取 sid **之前**——子代理会话不得污染会话状态(lib/session_state.ts
520
+ // 单点),否则顶层会话的自动召回会拿子代理的 session 去读(读错会话)。
521
+ // 字段缺失即不拦,见 isSubagentSession。
504
522
  if (isSubagentSession(session)) {
505
523
  ctx.logger.info('dsh-memory: 子代理会话的自动记忆被拦(H1:header.origin/delegationDepth)')
506
524
  audit.filtered('subagent')
@@ -508,9 +526,14 @@ export function installMemoryHooks(ctx: Context, mdcg: MdcgClient | null, opts:
508
526
  }
509
527
  // 会话归属(P45):记忆写入必须带会话身份,用来区分不同会话的记忆。
510
528
  // 空串 = 宿主未给出会话标识 → **显式标注 unassigned**(H2③:不落内核的进程级
511
- // 随机 sess_*,也不编造宿主会话 id——见 UNASSIGNED_SESSION 的注释)。
529
+ // 随机 sess_*,也不编造宿主会话 id——见 lib/session_state.ts 的常量注释)。
512
530
  const sid = sessionIdOf(session)
513
- if (sid) lastSession = sid
531
+ // 观测即记录(B 治本批):注入单点在 lib/session_state.ts——工具面
532
+ // (src/tools.ts 的写归因转发)读同一个状态;位置不动(H1 子代理闸之后)。
533
+ if (sid) {
534
+ noteSession(sid)
535
+ observedSession = true
536
+ }
514
537
  const sessionTag = sid ? { session: sid } : { session: UNASSIGNED_SESSION }
515
538
  if (event.type === 'user/message' && opts.userMessage) {
516
539
  // 落盘审计(issue #56):source.kind 分布——先于两级判据记录**完整**输入分布
@@ -0,0 +1,127 @@
1
+ /**
2
+ * session_state.ts —— 插件侧「运行期会话状态」单点(观测 → 写归因注入)
3
+ *
4
+ * 为什么有这个模块(B 治本批,2026-10-06)
5
+ * ----------------------------------------
6
+ * DSH 单进程多会话:宿主的会话标识只经 `session/event` 到达插件。此前它只被
7
+ * 用在 hooks 面(自动记忆的 remember / read 带 extra.session);**agent 直调
8
+ * 工具面**(src/tools.ts 的 execute → bridge.callTool)转发时**不带会话**——
9
+ * 而部署侧 `MDCG_SESSION` env 一旦取消/为空,`md_cg/mcp_server.py` 的
10
+ * `_declared_session`(:3823-3850)就以**请求声明**为归因来源
11
+ * (优先级:env > 请求声明 > 进程身份),不传即落回进程身份——md_cg 的
12
+ * `Principal.__init__` 会为假值 session 生成**进程级随机** `sess_<hex>`
13
+ * (md_cg/security.py:117):归属在审计上既读不出是谁、跨进程也对不上,
14
+ * 是静默的归属丢失。
15
+ *
16
+ * 本模块把「观测会话」与「写归因注入」收成**单点**:
17
+ * · hooks 面:每收到 session/event 就 `noteSession(sid)`——调用点仍在 H1
18
+ * 子代理闸**之后**(子代理会话不得污染会话状态的位置不变量保持);
19
+ * · 工具面:写归因调用注入 `currentSession() || UNASSIGNED_SESSION`
20
+ * (判据矩阵见 `attributeSession`,只注入写面;读面一律不注入)。
21
+ *
22
+ * 模块级状态(`lastSession`)是**进程级**的:同一进程内多个会话共享它,
23
+ * 「最近一次观测」即当前活动会话——与 hooks 面的既有口径一致(见
24
+ * test/session-attribution.test.ts ④「取值每步稳定」)。
25
+ */
26
+
27
+ /** 会话归属未知时的**显式占位**(H2③,2026-09-30;原定义在 src/hooks.ts,
28
+ * B 治本批迁入本单点——注释要点保真搬迁)。
29
+ *
30
+ * ⚠️ 不可退回「不传 session 键」:md_cg 的 `Principal.__init__` 在 session 为假值时
31
+ * 生成**进程级随机** `sess_<hex>`(md_cg/security.py:117)——插件不传,等于让一个
32
+ * 进程内所有「宿主未给标识」的会话共用一个**不可辨认**的随机桶:归属在审计上既
33
+ * 读不出是谁、跨进程也对不上,是静默的归属丢失。
34
+ * 本常量把这一态写成**显式值**:跨进程一致、可辨认、可审计,且不是伪造的宿主
35
+ * 会话 id(非 DSH 形态,服务端 `_normalize_session` 原样采用、不会被改写成别的桶)。
36
+ * 要读这个桶:`stg(op=timeline, session="unassigned")`。 */
37
+ export const UNASSIGNED_SESSION = 'unassigned'
38
+
39
+ /** 最近一次观测到的宿主会话标识(空串 = 未观测到会话)。
40
+ *
41
+ * ⚠️ 本状态是**模块级(进程级)单点**:hooks 面观测写入(src/hooks.ts 的
42
+ * session/event),工具面读它做写归因注入(src/tools.ts 的转发面)——两处必须
43
+ * 同源,否则注入的会话与写入的归属对不上(B 治本批的目的即在此)。
44
+ *
45
+ * 「本实例是否观测过」的**实例级门不在本模块**,在 src/hooks.ts 的
46
+ * installMemoryHooks 内(局部 `observedSession`):即「本实例观测到会话之后」
47
+ * 才用本单点值回落召回过滤。这是**经 owner 裁定的契约字面偏离(dwfq-7b3a555e-1)**
48
+ * ——契约建议的形态是 hooks 侧字面 `|| currentSession()`,但字面形态与硬边界
49
+ * 「test/session-attribution.test.ts 逐字未动且全绿」互斥(该守卫 ② 以
50
+ * 「新建 harness = 未观测」为前提,字面回落会读成前一实例的 sess_B)。保留
51
+ * 实例门的两条理由:
52
+ * ① 「新实例 = 干净状态」是原闭包变量 `lastSession` 的**有意属性**(新装的钩子
53
+ * 在观测到会话前,自动召回不加过滤)——实例隔离语义在测试面被真实保留;
54
+ * ② 与既有冻结守卫 ② 相容(守卫逐字未动且全绿是本批硬边界)。
55
+ * 真机单实例下两者**逐位等价**:唯一差异窗口是「本实例未观测 ∧ 模块单点非空」,
56
+ * 而模块值只由本实例的 hooks 观测写入,单实例下该窗口不可达;HMR 重载场景下新
57
+ * 实例回落 '' 而非上一会话值,属更保守的防御行为(已由 owner 裁定接受)。 */
58
+ let lastSession = ''
59
+
60
+ /** 记录一次会话观测(H2③ 观测面单点)。
61
+ *
62
+ * trim 后非空才写入——空串/纯空白**不覆盖**旧值(否则「宿主给了一次空标识」
63
+ * 会把已观测到的真会话抹掉,后续注入退化为 unassigned)。 */
64
+ export function noteSession(sid: string): void {
65
+ const s = sid.trim()
66
+ if (s) lastSession = s
67
+ }
68
+
69
+ /** 当前运行期会话标识(空串 = 未观测到;调用方按 `|| UNASSIGNED_SESSION` 兜底)。 */
70
+ export function currentSession(): string {
71
+ return lastSession
72
+ }
73
+
74
+ /** 写归因面判据:本次调用是否属于「要注入运行期会话」的写归因调用。
75
+ *
76
+ * **恰两条命中路径**(不得放宽、不得收窄出契约外):
77
+ * ① `mdcg_remember` —— md_cg 细粒度写入口(插件侧自动记忆/落图的写通道);
78
+ * ② `cg` 且 `op === 'write'` —— 认知图基元的带审核写路径。
79
+ *
80
+ * 为什么**只**这两个面(判据出处:md_cg/mcp_server.py):
81
+ * · **读面一律不注入**——`mdcg_recall` / `mdcg_search` / `mdcg_get` / `stg`
82
+ * 以及 `cg` 的其它 op,其 `session` 是**视图过滤**:服务端 schema 描述原文
83
+ * 「会话归属过滤(frontmatter.session;…缺省不过滤)」
84
+ * (md_cg/mcp_server.py:218-219 / :251-252 / :908-912 / :1074-1077)。
85
+ * 注入会把文档化的「缺省跨会话」翻转成「本会话视图」——那是功能收窄,
86
+ * 不是本批目标(读面要跨会话视图请显式 `stg(op=timeline, session="*")`)。
87
+ * · **op 特化语义不动**——`cg` 的 `sustain`/`session` 等 op 的 `session` 是
88
+ * 特化语义(resume/note 的**目标会话**),注入即污染其目标参数。
89
+ * · 归因与授权正交(issue #35 定稿):`call_tool` 的请求级 session **只做
90
+ * 归因**(写入归属/`_attribution` 取它),**不**改 `principal.session`
91
+ * (md_cg/mcp_server.py:3261-3264)——绑定档(private/secret)的读授权
92
+ * 锚定连接级身份,调用方自报的会话不构成看他人 private 的授权。
93
+ * 绑定档可见性判定 `MdCGSecure._readable`(md_cg/mdcos.py:5009-5053)
94
+ * 的会话绑定分支恒用 `nsess == self.principal.session`(:5051-5052),
95
+ * can_admin(设计者)豁免(:5045)——故本注入对绑定档无回归。
96
+ *
97
+ * ⚠️ 禁止把本判据放宽成「所有带 session 参数的工具」:那会把上面两类语义
98
+ * (读面视图过滤 / op 特化目标)一并改写,属越权改契约。
99
+ */
100
+ function isWriteAttributionCall(toolName: string, args: Record<string, unknown>): boolean {
101
+ if (toolName === 'mdcg_remember') return true
102
+ return toolName === 'cg' && args['op'] === 'write'
103
+ }
104
+
105
+ /** 请求是否已显式声明会话(string 且 trim 后非空 → 保留调用方声明,绝不覆盖)。 */
106
+ function hasDeclaredSession(args: Record<string, unknown>): boolean {
107
+ const v = args['session']
108
+ return typeof v === 'string' && v.trim() !== ''
109
+ }
110
+
111
+ /** 把**运行期会话**注入**写归因调用**(args 的 session 键不可用时才注入)。
112
+ *
113
+ * 注入条件:`session` 键缺失 / 非 string 类型 / trim 后空串 ⇒ 注入
114
+ * (`null`/`''`/空白一律视为「未声明」——服务端 `_declared_session` 对假值
115
+ * 同样解析为无归属声明,原样转发只会落回进程级随机 sess_* 兜底桶);
116
+ * 显式非空声明一律**不覆盖**(调用方自报优先——插件不替调用方改归属)。
117
+ *
118
+ * 注入值:`currentSession() || UNASSIGNED_SESSION`(未观测到会话时用
119
+ * 'unassigned' 显式占位——与 hooks 面 H2③ 同口径)。
120
+ *
121
+ * 命中且需注入 → 返回**新对象** `{ ...args, session: v }`(绝不 mutate 输入);
122
+ * 否则**原样返回**(同一引用)——未命中的调用零开销、零可观察差异。 */
123
+ export function attributeSession(toolName: string, args: Record<string, unknown>): Record<string, unknown> {
124
+ if (!isWriteAttributionCall(toolName, args)) return args
125
+ if (hasDeclaredSession(args)) return args
126
+ return { ...args, session: currentSession() || UNASSIGNED_SESSION }
127
+ }
package/src/tools.ts CHANGED
@@ -8,6 +8,10 @@
8
8
  import type { Context } from '@deepseek-ai/cordis'
9
9
  import { defineTool, type ParameterPropertySpec, type ParameterSchemaSpec, type ValueSchemaSpec } from '@deepseek-ai/dsh-tools'
10
10
  import type { LingshuBridge, McpTool } from './bridge.ts'
11
+ // B 治本批(2026-10-06):写归因注入单点(运行期会话 → 写归因调用)。
12
+ // 运行时导入写 `.js`(本仓口径:type-only 才写 `.ts`,tsconfig 未开
13
+ // allowImportingTsExtensions —— 值导入写 `.ts` 会 TS5097 编译失败)。
14
+ import { attributeSession } from './lib/session_state.js'
11
15
 
12
16
  /** 默认暴露的核心工具集合:**记忆面已基元化**,只注册 `cg` / `stg` 两个认知基元。
13
17
  *
@@ -186,7 +190,14 @@ export async function registerLingshuTools(
186
190
  // 此前完全忽略取消,取消后写操作(remember/relate/ingest 等)仍可能产生副作用
187
191
  async execute(args: Record<string, unknown>, exec: { signal: AbortSignal }) {
188
192
  if (exec.signal.aborted) throw new Error(`灵枢 ${tool.name} 已取消`)
189
- const result = await bridge.callTool(tool.name, args as Record<string, unknown>, exec.signal)
193
+ // B 治本批(2026-10-06):agent 直调工具的转发面把**运行期会话**注入
194
+ // **写归因调用**——env(MDCG_SESSION)取消后,请求声明即归因唯一来源
195
+ // (md_cg/mcp_server.py 的 _declared_session:env > 请求声明 > 进程身份)。
196
+ // 判据单点在 lib/session_state.ts 的 attributeSession:只注入
197
+ // mdcg_remember 与 cg(op=write) 两个写面;读面(视图过滤)/ op 特化语义
198
+ // 一律不动。命中且未显式声明时返回新对象,否则原样透传。
199
+ const forwarded = attributeSession(tool.name, args as Record<string, unknown>)
200
+ const result = await bridge.callTool(tool.name, forwarded, exec.signal)
190
201
  if (exec.signal.aborted) throw new Error(`灵枢 ${tool.name} 已取消`)
191
202
  if (result.isError) {
192
203
  throw new Error(extractText(result.content) || `灵枢 ${tool.name} 执行失败`)