@gavinc-cn/touchstone-dsh 0.1.1 → 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.
Files changed (90) hide show
  1. package/README.md +39 -11
  2. package/README.zh.md +25 -10
  3. package/backend/lib/access.js +44 -0
  4. package/backend/lib/agents.js +341 -0
  5. package/backend/lib/args.js +94 -0
  6. package/backend/lib/auth.js +195 -0
  7. package/backend/lib/board.js +6154 -0
  8. package/backend/lib/builtin-assets.js +1177 -0
  9. package/backend/lib/casescan.js +456 -0
  10. package/backend/lib/chat.js +2053 -0
  11. package/backend/lib/cookie.js +76 -0
  12. package/backend/lib/db.js +2260 -0
  13. package/backend/lib/dshdriver.js +1148 -0
  14. package/backend/lib/dshevents.js +851 -0
  15. package/backend/lib/export-cases.js +261 -0
  16. package/backend/lib/feishu.js +1738 -0
  17. package/backend/lib/feishu_cfg.js +228 -0
  18. package/backend/lib/feishu_conv.js +1484 -0
  19. package/backend/lib/feishu_device.js +428 -0
  20. package/backend/lib/feishu_inbound.js +1674 -0
  21. package/backend/lib/feishu_provision.js +855 -0
  22. package/backend/lib/feishu_rest.js +398 -0
  23. package/backend/lib/feishu_slash.js +568 -0
  24. package/backend/lib/feishu_ws.js +950 -0
  25. package/backend/lib/http.js +312 -0
  26. package/backend/lib/lib.js +973 -0
  27. package/backend/lib/loadcase.js +749 -0
  28. package/backend/lib/loadgen.js +1407 -0
  29. package/backend/lib/localbus.js +164 -0
  30. package/backend/lib/lock.js +405 -0
  31. package/backend/lib/proc.js +278 -0
  32. package/backend/lib/prompts.js +1021 -0
  33. package/backend/lib/pybase64.js +114 -0
  34. package/backend/lib/pyjson.js +214 -0
  35. package/backend/lib/query.js +125 -0
  36. package/backend/lib/rag.js +1310 -0
  37. package/backend/lib/runner.js +4520 -0
  38. package/backend/lib/servercaps.js +182 -0
  39. package/backend/lib/serverstate.js +237 -0
  40. package/backend/lib/sessparse.js +1176 -0
  41. package/backend/lib/sse.js +207 -0
  42. package/backend/lib/static.js +84 -0
  43. package/backend/lib/taskjson.js +302 -0
  44. package/backend/lib/waitq.js +2114 -0
  45. package/backend/lib/worktree.js +588 -0
  46. package/backend/lib/zstd-frames.js +218 -0
  47. package/backend/routes/admin.js +553 -0
  48. package/backend/routes/agents.js +149 -0
  49. package/backend/routes/assets.js +145 -0
  50. package/backend/routes/board.js +1220 -0
  51. package/backend/routes/feishu.js +269 -0
  52. package/backend/routes/feishu_app.js +215 -0
  53. package/backend/routes/feishu_hook.js +358 -0
  54. package/backend/routes/index.js +297 -0
  55. package/backend/routes/projects.js +1059 -0
  56. package/backend/routes/sessions.js +946 -0
  57. package/backend/routes/tasks.js +1530 -0
  58. package/backend/schema/fixtures/ancient.sql +189 -0
  59. package/backend/schema/fixtures/pre_alter.sql +177 -0
  60. package/backend/schema/migrations.json +154 -0
  61. package/backend/schema/schema.json +1451 -0
  62. package/backend/schema/schema.sql +216 -0
  63. package/backend/server.js +498 -0
  64. package/board.py +805 -72
  65. package/chat.py +590 -17
  66. package/db.py +356 -140
  67. package/dsh-plugin/cordis.patch.yml +7 -3
  68. package/dsh-plugin/install.sh +28 -11
  69. package/dsh-plugin/lib/agent-driver.js +710 -89
  70. package/dsh-plugin/lib/client.bundle.js +94 -9
  71. package/dsh-plugin/lib/index.js +529 -39
  72. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  73. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  74. package/dsh-plugin/src/client.js +94 -9
  75. package/dshdriver.py +74 -5
  76. package/dshevents.py +65 -3
  77. package/feishu.py +1197 -34
  78. package/feishu_conv.py +920 -0
  79. package/lifecycle.py +116 -13
  80. package/package.json +6 -1
  81. package/requirements.txt +2 -1
  82. package/server.py +218 -26
  83. package/sessparse.py +150 -44
  84. package/touchstone.py +6 -3
  85. package/waitq.py +23 -0
  86. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  87. package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
  88. package/webui/dist-plugin/index.html +2 -2
  89. package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
  90. package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
package/sessparse.py CHANGED
@@ -19,6 +19,11 @@
19
19
  tool_result {call_id, name, text, is_error, truncated}
20
20
  (dsh 事件流没有 usage 汇总事件与「本轮失败」独立通道,故不产出 usage / error 条目;
21
21
  响应里的 totals 字段保留为 0,维持 API 形状稳定。)
22
+
23
+ 上下文占用(会话窗底栏 ctx 圈,2026-10-10 修「恒为 0」):dsh 会话日志里有两个
24
+ 可用槽位——`request/context` 事件带**上下文窗口**(适配器声明,见 _parse_dsh)、
25
+ `assistant/message` 带本次调用的 **TokenUsage**;`load()` 在有窗口时附带
26
+ `ctx={"used","max"}`(分子口径=prompt 侧 token 数,见 _dsh_prompt_tokens)。
22
27
  """
23
28
 
24
29
  import base64
@@ -26,6 +31,7 @@ import glob
26
31
  import json
27
32
  import os
28
33
  import re
34
+ import threading
29
35
 
30
36
  import zstandard
31
37
 
@@ -70,13 +76,50 @@ def _obj(raw):
70
76
  return d if isinstance(d, dict) else None
71
77
 
72
78
 
79
+ def _nonneg_int(v):
80
+ """JSON 值 → 非负整数,其他(bool / 字符串 / 负数 / None)一律 None。
81
+
82
+ token 计数与窗口大小都必须是干净的整数:`True` 在 Python 里是 int 子类,
83
+ 直接参与算术会算出垃圾值。
84
+ """
85
+ return v if isinstance(v, int) and not isinstance(v, bool) and v >= 0 else None
86
+
87
+
88
+ def _dsh_prompt_tokens(usage):
89
+ """dsh `TokenUsage` → 本次请求的 **prompt 侧 token 数**(上下文占用分子)。
90
+
91
+ 口径照抄 dsh `dsh-token-meter` 的 `pressureFrom`:
92
+ `inputTokens + (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0)`=「这一次请求
93
+ 送进模型的上下文有多大」。**不能**用 totalTokens:它含本次回复的输出 token,
94
+ 环会随回复长度越画越大(且与上下文窗口不成比例)。inputTokens 缺失(老格式)
95
+ 退回 `totalTokens - outputTokens`;再取不到返回 None(调用方保留上一槽位值)。
96
+ """
97
+ if not isinstance(usage, dict):
98
+ return None
99
+ inp = _nonneg_int(usage.get("inputTokens"))
100
+ if inp is not None:
101
+ return inp + (_nonneg_int(usage.get("cacheReadTokens")) or 0) \
102
+ + (_nonneg_int(usage.get("cacheWriteTokens")) or 0)
103
+ total = _nonneg_int(usage.get("totalTokens"))
104
+ out = _nonneg_int(usage.get("outputTokens"))
105
+ if total is not None and out is not None and total >= out:
106
+ return total - out
107
+ return None
108
+
109
+
73
110
  class _Entries:
74
111
  """entry 收集器:自动分配 seq 并累计 totals。"""
75
112
 
76
113
  def __init__(self):
77
114
  self.entries = []
78
- # token/耗时汇总(API 契约字段):dsh 事件流无 usage 事件,恒为 0
115
+ # token/耗时汇总(API 契约字段):dsh 事件流无 usage 汇总事件,恒为 0
79
116
  self.totals = {"input": 0, "output": 0, "cache_read": 0, "duration_ms": 0}
117
+ # 上下文占用两槽位(会话窗底栏 ctx 圈,2026-10-10 修「恒为 0」):两个**独立
118
+ # last-wins** 槽位,口径与 dsh token-meter 的 `contextPressure` 投影一致:
119
+ # used = 最新一次 usage 的 prompt 侧 token 数(pressureTokens)
120
+ # max = 最新 `request/context` 事件的 contextWindow(适配器声明的路由窗口)
121
+ # None = 该槽位尚未出现过(老会话 / 适配器未声明窗口 / 还没有一次模型调用)。
122
+ self.ctx = {"used": None, "max": None}
80
123
  # dsh 回退锚点(仅 _parse_dsh 填充):[(mid, ese),...] —— 真实用户提问的
81
124
  # 合成 mid 与**会话事件 seq**;dsh 没有原地 undo,回退=按该 seq fork 新会话
82
125
  self.dsh_anchors = []
@@ -88,10 +131,30 @@ class _Entries:
88
131
 
89
132
  # ---------------------------------------------------------------- dsh 解析
90
133
 
91
- # dsh 会话文件为多帧 zstd 拼接(每帧一批 JSONL 事件行),用 zstandard 的
92
- # stream_reader(read_across_frames=True) 跨帧解压(项目唯一第三方依赖,见
93
- # requirements.txt)
94
- _DSH_DEC = zstandard.ZstdDecompressor()
134
+ # dsh 会话文件为多帧 zstd 拼接(每帧一批 JSONL 事件行),逐帧 `decompressobj` 解压
135
+ # (项目唯一第三方依赖,见 requirements.txt)。
136
+ #
137
+ # **解压器必须按线程隔离**(2026-10-09 定因的 SIGSEGV 实障):zstandard 的
138
+ # `ZstdDecompressor` **实例并发调用 `decompressobj()` 不是线程安全的**——原先这里
139
+ # 是模块级共享实例,而站点是多线程的(HTTP 请求线程池 + board 30s 调度线程 +
140
+ # 会话窗读口),并发解压直接 **SIGSEGV 打崩整个后端**(面板 502,薄壳不自愈)。
141
+ # 实测对照(8 线程 × 25s 反复解压真实会话文件):共享实例 **core dumped**;
142
+ # 每线程独立实例同负载 765 次解压**零崩溃**。崩溃现场见
143
+ # `doc_ai/bug_report/20261009_0600_插件后端两次SIGSEGV…`(faulthandler 栈落在
144
+ # `server.py:_api_get_board` 的 `session_title` 调用行,扩展模块清单只有
145
+ # `zstandard.backend_c`)。
146
+ _DEC_TLS = threading.local()
147
+
148
+
149
+ def _dec():
150
+ """取**本线程**的 zstd 解压器(无则新建并缓存,随线程回收)。
151
+
152
+ 不要改回模块级共享实例——那正是 SIGSEGV 的根因(见上方注释)。
153
+ """
154
+ d = getattr(_DEC_TLS, "d", None)
155
+ if d is None:
156
+ d = _DEC_TLS.d = zstandard.ZstdDecompressor()
157
+ return d
95
158
 
96
159
 
97
160
  def _dsh_decompressed(path):
@@ -115,7 +178,7 @@ def _dsh_decompressed(path):
115
178
  chunks = []
116
179
  rest = buf
117
180
  while rest:
118
- obj = _DSH_DEC.decompressobj()
181
+ obj = _dec().decompressobj()
119
182
  try:
120
183
  chunks.append(obj.decompress(rest))
121
184
  except zstandard.ZstdError:
@@ -193,7 +256,7 @@ def _dsh_header(path):
193
256
  for _ in range(_DSH_HEADER_FRAMES):
194
257
  if not rest:
195
258
  break
196
- obj = _DSH_DEC.decompressobj()
259
+ obj = _dec().decompressobj()
197
260
  try:
198
261
  text = obj.decompress(rest).decode("utf-8", "replace")
199
262
  except zstandard.ZstdError:
@@ -281,8 +344,9 @@ def dsh_latest_session(cwd):
281
344
  return best
282
345
 
283
346
 
284
- # path -> (stamp, title, first_prompt):标题与首问同源同缓存——两者都要全量解压
285
- # 会话文件,一次扫描一起取(30s 同步节拍里同一文件只解压一次)
347
+ # path -> (stamp, title, first_prompt, titles):当前标题、首问与全部标题事件同源
348
+ # 同缓存——三者都要全量解压会话文件,一次扫描一起取(30s 同步节拍里同一文件只解压
349
+ # 一次)
286
350
  _DSH_TITLE_CACHE = {}
287
351
 
288
352
 
@@ -297,15 +361,31 @@ def _line_at(text, idx):
297
361
  return text[start:] if end < 0 else text[start:end]
298
362
 
299
363
 
300
- def _dsh_scan_title(text):
301
- """首个 session/title 事件的标题(无该事件 / 解析失败返回 '')。"""
302
- idx = text.find('"session/title"')
303
- if idx < 0:
304
- return ""
305
- d = _obj(_line_at(text, idx))
306
- if d is None or d.get("type") != "session/title":
307
- return ""
308
- return str((d.get("data") or {}).get("title") or "")
364
+ def _dsh_scan_titles(text):
365
+ """全部 `session/title` 事件的标题,按文件顺序(无事件返回 [])。
366
+
367
+ **末枚即 DSH 当前标题**(dsh 对同一会话会写多枚:先 fallback 截断句、再由 LLM
368
+ 标题覆盖、用户显式改名再追加;GUI 侧栏显示 last-wins 的那一枚)。平台侧一律
369
+ 取同一口径(会话窗 / 任务标题 / 看板 sync 卡标题);`list_sessions` 另把整串
370
+ 下发给看板,用于判定「卡面标题是否仍是平台自动写入的形态」。
371
+
372
+ 逐次 find + **整行 JSON 校验**(同 `_dsh_scan_prompt`):非事件行里出现同名
373
+ 字符串(坏行 / 别处文本)一律跳过;空标题事件跳过(dsh 不会写空标题,真出现
374
+ 时按「不表态」处理,避免把当前标题抹成空串)。
375
+ """
376
+ out = []
377
+ pos = 0
378
+ while True:
379
+ idx = text.find('"session/title"', pos)
380
+ if idx < 0:
381
+ return out
382
+ pos = idx + 1
383
+ d = _obj(_line_at(text, idx))
384
+ if d is None or d.get("type") != "session/title":
385
+ continue
386
+ title = str((d.get("data") or {}).get("title") or "")
387
+ if title:
388
+ out.append(title)
309
389
 
310
390
 
311
391
  def _dsh_scan_prompt(text):
@@ -313,7 +393,7 @@ def _dsh_scan_prompt(text):
313
393
 
314
394
  判定与会话窗口的 user entry 同口径:`user/message` 且 `source.kind == "user"`
315
395
  (系统注入的 plugin / agent-instructions 消息被过滤);文本块按换行拼接、
316
- **原样保留内部换行**——调用方(看板同步卡)再按「首行进标题、其余进描述」切。
396
+ **原样保留内部换行**——调用方(看板同步卡)直接作为卡描述(首问全文)。
317
397
  只有图片没有文本的提问返回 `[图片]`(沿用会话窗的图片占位口径)。
318
398
 
319
399
  「第一条」按**文件顺序**取(会话窗的展示顺序;真机 20 个会话核对:文件序首条
@@ -342,31 +422,33 @@ def _dsh_scan_prompt(text):
342
422
 
343
423
 
344
424
  def _dsh_meta(path):
345
- """一次解压取出 (会话标题, 主会话首问原文),按文件 stamp 缓存。
346
-
347
- - 会话标题 = **首个** `session/title` 事件的 title。dsh 对同一会话会写多枚标题
348
- 事件(先 fallback 截断句、再由 LLM 标题覆盖、用户显式改名再追加),本平台
349
- 此处沿用既有「首个」口径(会话窗 / 任务标题同源),与 dsh GUI 的 last-wins
350
- 当前标题不一致——差异与取舍见 doc_ai/spec/board/看板增强.md「sync 会话归类」。
351
- - 首问 = 见 `_dsh_scan_prompt`,是看板 sync 卡标题/描述的数据源。
425
+ """一次解压取出 (当前标题, 主会话首问原文, 全部标题事件),按文件 stamp 缓存。
426
+
427
+ - 当前标题 = `titles` 的**末枚**(`session/title` 事件 last-wins)= dsh GUI 侧栏
428
+ 显示的标题(口径说明见 `_dsh_scan_titles`);
429
+ - 首问 = 见 `_dsh_scan_prompt`,是看板 sync 卡描述(首问全文)的数据源;
430
+ - 全部标题事件(tuple)= 该会话历史上出现过的每个标题,看板 sync 卡据此判定
431
+ 「卡面标题是否仍是平台自动写入的形态」(建卡时只有兜底标题、LLM 标题后到的
432
+ 窗口里,旧的兜底标题只存在于事件历史中)。
352
433
  """
353
434
  try:
354
435
  st = os.stat(path)
355
436
  stamp = (st.st_mtime_ns, st.st_size)
356
437
  except OSError:
357
- return "", ""
438
+ return "", "", ()
358
439
  hit = _DSH_TITLE_CACHE.get(path)
359
440
  if hit and hit[0] == stamp:
360
- return hit[1], hit[2]
441
+ return hit[1], hit[2], hit[3]
361
442
  text = _dsh_decompressed(path)
362
- title = _dsh_scan_title(text)
443
+ titles = tuple(_dsh_scan_titles(text))
444
+ title = titles[-1] if titles else ""
363
445
  prompt = _dsh_scan_prompt(text)
364
- _DSH_TITLE_CACHE[path] = (stamp, title, prompt)
365
- return title, prompt
446
+ _DSH_TITLE_CACHE[path] = (stamp, title, prompt, titles)
447
+ return title, prompt, titles
366
448
 
367
449
 
368
450
  def dsh_title(sid):
369
- """读取 dsh 会话标题(首个 session/title 事件),无/不可读返回 ''。"""
451
+ """读取 dsh 会话当前标题(最后一枚 session/title 事件),无/不可读返回 ''。"""
370
452
  path = _dsh_session_file(sid)
371
453
  return _dsh_meta(path)[0] if path else ""
372
454
 
@@ -374,7 +456,7 @@ def dsh_title(sid):
374
456
  def dsh_first_prompt(sid):
375
457
  """读取主会话第一条真实用户提问的原文(可多行),无/不可读返回 ''。
376
458
 
377
- 看板 sync 卡的标题/描述数据源(见 board.sync_sessions):会话还没收到提问时
459
+ 看板 sync 卡的描述数据源(见 board.sync_sessions):会话还没收到提问时
378
460
  返回 '',调用方按会话标题 / sid 短码兜底,首问落盘后由同步节拍补齐。
379
461
  """
380
462
  path = _dsh_session_file(sid)
@@ -432,6 +514,18 @@ def _parse_dsh(path):
432
514
  col.add("think", tm, text=b.get("text", ""))
433
515
  elif b.get("type") == "text":
434
516
  col.add("assistant", tm, text=b.get("text", ""))
517
+ # 上下文占用分子(会话窗 ctx 圈):每次模型调用结算的 prompt 侧 token 数,
518
+ # last-wins(无 usage 的中断消息不参与,槽位保留上一次可用的值)
519
+ used = _dsh_prompt_tokens(data.get("usage"))
520
+ if used is not None:
521
+ col.ctx["used"] = used
522
+ elif t == "request/context":
523
+ # 上下文占用分母(会话窗 ctx 圈):宿主为该路由解析出的上下文窗口
524
+ # (适配器声明才有;中途 /model 换模型会再落一条,故 last-wins)。
525
+ # 0/缺失一律忽略——窗口必须为正才有意义(宿主 schema 也是 positive)。
526
+ win = _nonneg_int(data.get("contextWindow"))
527
+ if win:
528
+ col.ctx["max"] = win
435
529
  elif t == "tool/call":
436
530
  cid = data.get("callId", "")
437
531
  tool_names[cid] = data.get("name", "")
@@ -548,6 +642,8 @@ def load(family, sid, agent, after=0):
548
642
  或 {"found": False, "reason": "missing" | "unsupported"}。
549
643
  entries 为 seq >= after 的增量;total 为全量条数(变小时客户端应重置重拉)。
550
644
  dsh 为单线会话(固定 main,无子 agent)。
645
+ 另在**分母(上下文窗口)已知**时附带 `ctx`(会话窗底栏占用圈的数据源,
646
+ 见 _dsh_prompt_tokens / `request/context`);窗口未知时**不带**该键。
551
647
  """
552
648
  if family not in FAMILIES:
553
649
  return {"found": False, "reason": "unsupported"}
@@ -564,14 +660,21 @@ def load(family, sid, agent, after=0):
564
660
  cache = _CACHE.get(key)
565
661
  if cache is None or cache["stamp"] != stamp:
566
662
  col = _parse_dsh(zpath)
567
- cache = {"stamp": stamp, "entries": col.entries, "totals": col.totals}
663
+ cache = {"stamp": stamp, "entries": col.entries, "totals": col.totals,
664
+ "ctx": col.ctx}
568
665
  _CACHE[key] = cache
569
666
  except (OSError, ValueError):
570
667
  return {"found": False, "reason": "missing"}
571
668
  entries = cache["entries"]
572
669
  after = max(0, int(after or 0))
573
- return {"found": True, "agents": agents, "agent": agent,
574
- "entries": entries[after:], "total": len(entries), "totals": cache["totals"]}
670
+ out = {"found": True, "agents": agents, "agent": agent,
671
+ "entries": entries[after:], "total": len(entries), "totals": cache["totals"]}
672
+ # 上下文占用(会话窗底栏 ctx 圈,2026-10-10):只在**分母已知**时下发——前端按
673
+ # `+ctx.max > 0` 决定画不画进度弧,窗口未知时给了也只能画成 0%(本次修的旧形态)。
674
+ # 分子缺省 0:窗口已知但还没有一次模型调用=空上下文,0% 是真实读数。
675
+ if cache["ctx"]["max"]:
676
+ out["ctx"] = {"used": cache["ctx"]["used"] or 0, "max": cache["ctx"]["max"]}
677
+ return out
575
678
 
576
679
 
577
680
  def resolve_media(family, sid, agent, media_id):
@@ -652,7 +755,8 @@ def _dsh_archived_ids():
652
755
  def session_title(family, sid):
653
756
  """统一会话标题读取(异常静默返回 '',标题只是展示增强,不打断主流程)。
654
757
 
655
- dsh 标题取会话 `session/title` 事件;族不在白名单返回 ''。
758
+ dsh 标题取会话**当前**标题(末枚 `session/title` 事件,与 dsh GUI 侧栏一致);
759
+ 族不在白名单返回 ''。
656
760
  """
657
761
  if family not in FAMILIES:
658
762
  return ""
@@ -688,20 +792,22 @@ def _list_dsh(cwd):
688
792
  sid = os.path.basename(sdir)
689
793
  if _header_subagent(_dsh_header(zfile)):
690
794
  continue # 子代理会话:先判头行再解全量(省一次整段解压)
691
- # 一次解压同时取标题与首问(同 stamp 缓存):看板 sync 卡建卡/补齐标题都要
692
- # 首问,逐会话各调一次读口会重复解压同一个文件(见 _dsh_meta)
693
- title, first_prompt = _dsh_meta(zfile)
694
- out.append({"sid": sid, "title": title, "first_prompt": first_prompt,
795
+ # 一次解压同时取当前标题、首问与全部标题事件(同 stamp 缓存):看板 sync 卡
796
+ # 建卡/标题同步都要后两者,逐会话各调一次读口会重复解压同一个文件(见 _dsh_meta)
797
+ title, first_prompt, titles = _dsh_meta(zfile)
798
+ out.append({"sid": sid, "title": title, "titles": list(titles),
799
+ "first_prompt": first_prompt,
695
800
  "mtime": mtime, "archived": sid in archived})
696
801
  return out
697
802
 
698
803
 
699
804
  def list_sessions(family, cwd):
700
- """按工作区枚举 dsh 会话 [{sid, title, first_prompt, mtime秒, archived}],
805
+ """按工作区枚举 dsh 会话 [{sid, title, titles, first_prompt, mtime秒, archived}],
701
806
  mtime 降序、上限 LIST_SESSIONS_LIMIT。
702
807
 
703
- `title` = 首个会话标题事件,`first_prompt` = 主会话第一条真实用户提问原文
704
- (看板 sync 卡取它做「首行=标题、其余=描述」)。
808
+ `title` = **当前**会话标题(末枚 session/title 事件,与 dsh GUI 一致)、
809
+ `titles` = 全部标题事件、`first_prompt` = 主会话第一条真实用户提问原文
810
+ (看板 sync 卡取它做描述、取 title 做卡标题)。
705
811
 
706
812
  供看板「绑定已有会话」下拉与「自动同步」共用;`archived` 读宿主归档集
707
813
  (`<dsh home>/storages/workspace.json` 的 `global.archivedSessionIds`,读不到
package/touchstone.py CHANGED
@@ -6,7 +6,7 @@ Windows 原生运行的首选入口(bash/nohup/kill 不可用),Linux 上
6
6
  .run/server.log)。用法:
7
7
 
8
8
  python3 touchstone.py start|stop|restart|status|build|test|test-full|test-ui
9
- # Windows: python touchstone.py 或 touchstone.cmd
9
+ # Windows: python touchstone.py 或 touchstone.cmd(后者自动解析 python → python3)
10
10
 
11
11
  行为对齐 touchstone.sh:
12
12
  - start / restart 先按需构建前端(npm install 按需:依赖指纹未变化则跳过,
@@ -117,8 +117,11 @@ def precheck_deps():
117
117
  import requests # noqa: F401
118
118
  import zstandard # noqa: F401
119
119
  except ImportError as e:
120
- print_err(f"错误: python3 缺少运行依赖({e}),请先执行:")
121
- print_err(" python3 -m pip install -r requirements.txt")
120
+ # 用当前解释器自己的路径提示(不写死 python3/python): Windows 官方安装器只有
121
+ # `python.exe`、Linux 惯例是 `python3`, 报用户机器上真正在跑的那个最不容易误导。
122
+ py = sys.executable or "python"
123
+ print_err(f"错误: 当前解释器缺少运行依赖({e}),请先执行:")
124
+ print_err(f" {py} -m pip install -r requirements.txt")
122
125
  sys.exit(1)
123
126
 
124
127
 
package/waitq.py CHANGED
@@ -727,6 +727,9 @@ def selfcheck(probe=None):
727
727
  的扫描集内**——③ 只取 `kind=KIND_CARD` 的活跃行,ext 行永不被 ③ 覆盖
728
728
  (v3 终审修复:原文「③ 只对『卡行已删』形态告警」不成立;③ 的实际报告面
729
729
  是两类 c: 行形态——卡不在 doing(列已非开发容器)+ 卡行已删(行无主))。
730
+ 反向形态「占位在、行不在」(doing+queue 卡无活跃 c: 行)**不在本函数面**:
731
+ 它要读 board_cards 的排队占位语义,归 `board._heal_queue_placeholders`
732
+ 的 30s 节拍(报告 + 补建一体,2026-10-10 实障卡 936)。
730
733
  probe:行判活探针(web busy 三态;`selfcheck_units` 透传,直调缺省 None)。
731
734
  """
732
735
  problems = []
@@ -1216,6 +1219,26 @@ def msg_get(msg_id):
1216
1219
  (str(msg_id),)).fetchone()
1217
1220
 
1218
1221
 
1222
+ def msg_meta(msg_id):
1223
+ """消息等待项 meta(取该 target 的**最后一行**,含终态行):飞书回流钩子在
1224
+ 终态收口之后读「提交时定格」的载荷(feishu 命名空间)。无行返回 {},坏 JSON 亦 {}。
1225
+
1226
+ 只读口:与 msg_get 相邻;口径上不看 state(收口已把行落终态,活跃口径读不到),
1227
+ 也不去 join chat_msgs——载荷只在等待项行上(chat_msgs 无 meta 列)。
1228
+ """
1229
+ with db.connect() as conn:
1230
+ row = conn.execute(
1231
+ "SELECT meta FROM wait_items WHERE kind=? AND target_id=?"
1232
+ " ORDER BY id DESC LIMIT 1", (KIND_MSG, str(msg_id))).fetchone()
1233
+ if row is None:
1234
+ return {}
1235
+ try:
1236
+ meta = json.loads(row["meta"] or "{}")
1237
+ except ValueError:
1238
+ return {}
1239
+ return meta if isinstance(meta, dict) else {}
1240
+
1241
+
1219
1242
  def msg_rows(sid=None, card_id=None, states=None):
1220
1243
  """消息行查询(chat 读侧专用):sid/card_id/states 可选过滤,created_at 升序
1221
1244
  (idx_chat_msgs_sid 覆盖 sid 过滤);states 传元组。"""