dev-memory-cli 0.22.1 → 0.23.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/README.md +255 -291
- package/bin/dev-memory.js +125 -2
- package/hooks/README.md +181 -47
- package/hooks/codex-hooks.json +1 -1
- package/hooks/hooks.json +1 -1
- package/lib/dev_memory_capture.py +152 -11
- package/lib/dev_memory_common.py +68 -19
- package/lib/dev_memory_graduate.py +1 -1
- package/lib/dev_memory_session_scan.py +1565 -0
- package/lib/dev_memory_setup.py +8 -3
- package/lib/dev_memory_summary.py +25 -16
- package/lib/maintenance/archive.md +84 -0
- package/lib/maintenance/tidy.md +103 -0
- package/lib/ui-app.html +128 -1
- package/lib/ui-server.js +67 -1
- package/package.json +2 -1
- package/scripts/hooks/_common.py +48 -12
- package/scripts/hooks/stop.py +6 -0
- package/suite-manifest.json +4 -4
package/lib/dev_memory_setup.py
CHANGED
|
@@ -6,7 +6,7 @@ lazy-inits files on first write. Setup's job is now:
|
|
|
6
6
|
|
|
7
7
|
1. Ensure skeleton exists (idempotent).
|
|
8
8
|
2. Scan unsorted.md and present each entry to the user for classification.
|
|
9
|
-
3. Route user's choices into decisions/
|
|
9
|
+
3. Route user's choices into decisions/risks/glossary/shared-*.
|
|
10
10
|
4. Mark manifest.setup_completed = true.
|
|
11
11
|
"""
|
|
12
12
|
|
|
@@ -92,7 +92,7 @@ def command_init(args):
|
|
|
92
92
|
# plan.json format:
|
|
93
93
|
# {
|
|
94
94
|
# "classifications": [
|
|
95
|
-
# {"entry": "original bullet text", "kind": "decision|
|
|
95
|
+
# {"entry": "original bullet text", "kind": "decision|risk|glossary|source|shared-*|skip"},
|
|
96
96
|
# ...
|
|
97
97
|
# ],
|
|
98
98
|
# "clear_unsorted_on_done": true
|
|
@@ -138,7 +138,12 @@ def _apply_classifications(paths, classifications):
|
|
|
138
138
|
from dev_memory_common import append_to_section
|
|
139
139
|
body = "\n".join(f"- {e}" for e in entries)
|
|
140
140
|
path = paths[file_key]
|
|
141
|
-
append_to_section(
|
|
141
|
+
append_to_section(
|
|
142
|
+
path,
|
|
143
|
+
section,
|
|
144
|
+
body,
|
|
145
|
+
max_entries=20 if file_key.startswith("repo_") else None,
|
|
146
|
+
)
|
|
142
147
|
tally[f"{file_key}:{section}"] = len(entries)
|
|
143
148
|
return {"applied": tally, "skipped": skipped}
|
|
144
149
|
|
|
@@ -68,16 +68,26 @@ def _extract_codex(obj):
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
|
|
71
|
-
def _iter_core_messages(transcript_path):
|
|
71
|
+
def _iter_core_messages(transcript_path, since_size=0):
|
|
72
72
|
if not transcript_path:
|
|
73
73
|
return []
|
|
74
74
|
path = Path(transcript_path).expanduser()
|
|
75
75
|
if not path.exists():
|
|
76
76
|
return []
|
|
77
77
|
out = []
|
|
78
|
-
with path.open(
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
with path.open("rb") as f:
|
|
79
|
+
while True:
|
|
80
|
+
start = f.tell()
|
|
81
|
+
raw = f.readline()
|
|
82
|
+
if not raw:
|
|
83
|
+
break
|
|
84
|
+
end = f.tell()
|
|
85
|
+
if end <= since_size:
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
line = raw.decode("utf-8").strip()
|
|
89
|
+
except UnicodeDecodeError:
|
|
90
|
+
continue
|
|
81
91
|
if not line:
|
|
82
92
|
continue
|
|
83
93
|
try:
|
|
@@ -86,12 +96,16 @@ def _iter_core_messages(transcript_path):
|
|
|
86
96
|
continue
|
|
87
97
|
msg = _extract_claude(obj) or _extract_codex(obj)
|
|
88
98
|
if msg:
|
|
99
|
+
msg["start_offset"] = start
|
|
100
|
+
msg["end_offset"] = end
|
|
89
101
|
out.append(msg)
|
|
90
102
|
return out
|
|
91
103
|
|
|
92
104
|
|
|
93
105
|
def _truncate(text, max_chars):
|
|
94
106
|
text = (text or "").strip()
|
|
107
|
+
if not max_chars or max_chars < 0:
|
|
108
|
+
return text
|
|
95
109
|
if len(text) <= max_chars:
|
|
96
110
|
return text
|
|
97
111
|
return text[: max_chars - 3].rstrip() + "..."
|
|
@@ -142,8 +156,8 @@ def _summary_job(job):
|
|
|
142
156
|
def extract_core_payload(
|
|
143
157
|
job,
|
|
144
158
|
*,
|
|
145
|
-
max_messages=
|
|
146
|
-
max_message_chars=
|
|
159
|
+
max_messages=0,
|
|
160
|
+
max_message_chars=0,
|
|
147
161
|
max_memory_chars=6000,
|
|
148
162
|
since_size=0,
|
|
149
163
|
include_message_metadata=False,
|
|
@@ -159,14 +173,9 @@ def extract_core_payload(
|
|
|
159
173
|
("repo/decisions.md", repo_dir / "repo" / "decisions.md"),
|
|
160
174
|
("repo/glossary.md", repo_dir / "repo" / "glossary.md"),
|
|
161
175
|
]
|
|
162
|
-
messages = _iter_core_messages(job.get("transcript_path"))
|
|
163
|
-
if since_size:
|
|
164
|
-
# JSONL is line-oriented but byte offsets can land mid-line. Keep this
|
|
165
|
-
# option conservative for now: expose cursor metadata and let workers
|
|
166
|
-
# use recent-tail until a precise offset reader is needed.
|
|
167
|
-
pass
|
|
176
|
+
messages = _iter_core_messages(job.get("transcript_path"), since_size=since_size)
|
|
168
177
|
messages = [m for m in messages if not _is_nonsemantic_user_text(m["text"])]
|
|
169
|
-
recent = messages[-max_messages:]
|
|
178
|
+
recent = messages[-max_messages:] if max_messages and max_messages > 0 else messages
|
|
170
179
|
core_messages = []
|
|
171
180
|
for m in recent:
|
|
172
181
|
item = {
|
|
@@ -213,10 +222,10 @@ def main():
|
|
|
213
222
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
214
223
|
p = sub.add_parser("extract-core", help="Extract core transcript messages and current memory for a summary job")
|
|
215
224
|
p.add_argument("job", help="Path to a session-summary pending job JSON")
|
|
216
|
-
p.add_argument("--max-messages", type=int, default=
|
|
217
|
-
p.add_argument("--max-message-chars", type=int, default=
|
|
225
|
+
p.add_argument("--max-messages", type=int, default=0, help="0 keeps every semantic message")
|
|
226
|
+
p.add_argument("--max-message-chars", type=int, default=0, help="0 keeps complete message text")
|
|
218
227
|
p.add_argument("--max-memory-chars", type=int, default=6000)
|
|
219
|
-
p.add_argument("--since-size", type=int, default=0, help="
|
|
228
|
+
p.add_argument("--since-size", type=int, default=0, help="Read JSONL records ending after this byte offset")
|
|
220
229
|
p.add_argument(
|
|
221
230
|
"--include-message-metadata",
|
|
222
231
|
action="store_true",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# 分支记忆归档维护流程
|
|
2
|
+
|
|
3
|
+
目标是把已完成分支中真正跨分支复用的知识提炼到 repo 共享层,然后归档该分支的记忆目录。归档是破坏性显式动作,必须由用户确认。
|
|
4
|
+
|
|
5
|
+
## 硬规则
|
|
6
|
+
|
|
7
|
+
- 始终只处理提示中指定的 repo + branch。
|
|
8
|
+
- 所有命令都使用提示中给出的 CLI 路径,并显式传 `--repo`、必要时传 `--branch`。
|
|
9
|
+
- 禁止跳过 dry-run 直接 apply。
|
|
10
|
+
- 当前分支存在未提交改动、领先默认基线且尚未 merge,或仍在开发时,不得擅自归档;先向用户说明并确认。
|
|
11
|
+
- pending-promotion 只是候选,不是必选。业务专用事实不得上提到 repo 共享层。
|
|
12
|
+
- 上提决策必须保留 Why 与影响范围,并改写成脱离当前分支名称仍可理解的最终事实。
|
|
13
|
+
|
|
14
|
+
## 第一阶段:pre-flight
|
|
15
|
+
|
|
16
|
+
运行:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
<CLI> graduate dry-run --repo <REPO> [--branch <BRANCH>]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
检查并向用户报告:
|
|
23
|
+
|
|
24
|
+
- `git_status.ahead`
|
|
25
|
+
- `git_status.uncommitted`
|
|
26
|
+
- 默认基线与当前分支
|
|
27
|
+
- `archive_destination`
|
|
28
|
+
- 主审核面 `primary_sources.pending-promotion.md`
|
|
29
|
+
- 主审核面 `primary_sources.decisions.md`
|
|
30
|
+
- 其他 `cross_check_sources` 中可能遗漏的跨分支经验
|
|
31
|
+
|
|
32
|
+
若 pre-flight 暴露未 merge 或未提交状态,停止在确认点,不要继续生成 apply。
|
|
33
|
+
|
|
34
|
+
## 第二阶段:生成 harvest 草案
|
|
35
|
+
|
|
36
|
+
只提炼真正跨分支稳定成立的内容,生成:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"repo_overview": [
|
|
41
|
+
{"section": "长期目标与边界", "body": "...", "mode": "append"}
|
|
42
|
+
],
|
|
43
|
+
"repo_decisions": [
|
|
44
|
+
{"section": "跨分支通用决策", "body": "...", "mode": "append"}
|
|
45
|
+
],
|
|
46
|
+
"repo_glossary": [
|
|
47
|
+
{"section": "长期有效背景", "body": "...", "mode": "append"},
|
|
48
|
+
{"section": "共享入口", "body": "...", "mode": "append"}
|
|
49
|
+
],
|
|
50
|
+
"notes": "从目标分支提炼",
|
|
51
|
+
"archive": true
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
提炼时:
|
|
56
|
+
|
|
57
|
+
- 去掉当前需求名、临时人员分工、一次性状态和提交流水。
|
|
58
|
+
- 保留通用约束、反直觉风险、长期资料入口和可复用流程。
|
|
59
|
+
- 对照现有 repo 共享记忆去重;旧共享结论失效时应改成最终口径,不能制造两条冲突规则。
|
|
60
|
+
- 默认 `mode=append`,除非已经确认需要更新已有 section。
|
|
61
|
+
|
|
62
|
+
把 harvest 草案完整展示给用户,包括“会上提什么”和“只归档、不做上提什么”。获得明确确认之前禁止 apply。
|
|
63
|
+
|
|
64
|
+
## 第三阶段:apply
|
|
65
|
+
|
|
66
|
+
用户确认草案与归档目标后运行:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
<CLI> graduate apply --repo <REPO> [--branch <BRANCH>] --harvest-file <HARVEST>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
apply 会串行写入 repo 共享层,生成 `archive_summary.md`,并把分支目录移动到 `branches/_archived/<branch>__<date>/`,同时更新 `_archived/INDEX.md`。
|
|
73
|
+
|
|
74
|
+
## 第四阶段:复核
|
|
75
|
+
|
|
76
|
+
完成后必须:
|
|
77
|
+
|
|
78
|
+
1. 读取 `archive_summary.md`。
|
|
79
|
+
2. 运行 `<CLI> graduate index --repo <REPO>` 确认归档索引存在。
|
|
80
|
+
3. 检查原 branch_dir 已移动、archive destination 存在。
|
|
81
|
+
4. 汇总实际上提条目、归档路径和任何跳过项。
|
|
82
|
+
|
|
83
|
+
如果 apply 报 branch 不存在、schema 漂移或并发归档冲突,停止并报告真实错误;不得强行移动目录或手写 INDEX 绕过 CLI。
|
|
84
|
+
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# 记忆整理维护流程
|
|
2
|
+
|
|
3
|
+
目标是把未分类内容归位,并校准已经结构化但陈旧、重复、错误或残留模板的记忆。整理是显式维护任务,不在普通开发会话中自动触发。
|
|
4
|
+
|
|
5
|
+
## 硬规则
|
|
6
|
+
|
|
7
|
+
- 始终只处理提示中指定的单个 repo + branch。
|
|
8
|
+
- 所有命令都使用提示中给出的 CLI 路径,并显式传 `--repo`、必要时传 `--branch`。
|
|
9
|
+
- 未分类内容可以在用户确认分类后 merge;结构化内容的删除和改写必须先生成 HTML review,由用户审核并导出 plan,禁止直接 apply。
|
|
10
|
+
- 不复制 PRD、聊天流水或 Git 历史;只保留下次开发仍有价值的决策、风险、术语、约束、入口与文件导航。
|
|
11
|
+
- `progress.md` 的 `AUTO-GENERATED` 块不得删除或改写。
|
|
12
|
+
|
|
13
|
+
## 第一阶段:处理 unsorted
|
|
14
|
+
|
|
15
|
+
先运行:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
<CLI> setup init --repo <REPO> [--branch <BRANCH>]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
如果 `unsorted_entries` 非空:
|
|
22
|
+
|
|
23
|
+
1. 把相关条目聚合后给出高置信度分类建议。
|
|
24
|
+
2. 可选 kind:`decision`、`risk`、`glossary`、`source`、`shared-decision`、`shared-context`、`shared-source`、`skip`。
|
|
25
|
+
3. 让用户确认或修正分类;未确认前不要 merge。
|
|
26
|
+
4. 生成 `classifications` plan,并运行 `setup merge-unsorted --plan-file ...`。
|
|
27
|
+
|
|
28
|
+
如果 unsorted 为空,不需要为了修改 `setup_completed` 制造额外步骤;继续结构化整理即可。
|
|
29
|
+
|
|
30
|
+
## 第二阶段:扫描结构化记忆
|
|
31
|
+
|
|
32
|
+
运行:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
<CLI> tidy prepare --repo <REPO> [--branch <BRANCH>] --scope <SCOPE>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`SCOPE` 使用启动提示中的 `branch` 或 `branch+repo`。第一次 prepare 后优先读取输出中的 `annotated_md`,不要只看 stdout 的扁平 entries。它保留了:
|
|
39
|
+
|
|
40
|
+
- entry id
|
|
41
|
+
- block 边界
|
|
42
|
+
- section 结构
|
|
43
|
+
- Why/How 等 orphan paragraph
|
|
44
|
+
- STALE / ORPHAN hints
|
|
45
|
+
|
|
46
|
+
## 第三阶段:生成事项级 proposals
|
|
47
|
+
|
|
48
|
+
把相关条目聚合成约 3~8 个 proposal,每个 proposal 必须包含 `id`、`priority`、`title`、`reason`、`actions`。优先使用粗粒度动作:
|
|
49
|
+
|
|
50
|
+
1. `reset-file`
|
|
51
|
+
2. `delete-section`
|
|
52
|
+
3. `delete-block`
|
|
53
|
+
4. `delete-entries`
|
|
54
|
+
5. `edit-entries`
|
|
55
|
+
|
|
56
|
+
不要把每个 entry 单独做成 proposal。同一原因、同一结果的动作应合并。删除 block 时尽量附带 prepare 输出的 `expected_content_hash`,防止用户中途修改文件后误删。
|
|
57
|
+
|
|
58
|
+
priority 口径:
|
|
59
|
+
|
|
60
|
+
- `P0`:继续保留会误导下一次开发
|
|
61
|
+
- `P1`:强烈建议清理
|
|
62
|
+
- `P2`:建议清理
|
|
63
|
+
- `P3`:可选
|
|
64
|
+
- `P4`:可有可无
|
|
65
|
+
|
|
66
|
+
把 proposals 写入临时 JSON,再次运行:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
<CLI> tidy prepare --repo <REPO> [--branch <BRANCH>] --scope <SCOPE> --proposals-file <FILE>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 第四阶段:人工审核门禁
|
|
73
|
+
|
|
74
|
+
把 `review_html` / `open_url` 告诉用户,引导用户逐个选择:
|
|
75
|
+
|
|
76
|
+
- accept:按原方案执行
|
|
77
|
+
- reject:保持不动
|
|
78
|
+
- custom:给出新的处理意见
|
|
79
|
+
|
|
80
|
+
用户审核后从 HTML 导出 plan.json,并把路径交回本会话。没有用户导出的 plan 文件,禁止调用 `tidy apply`。
|
|
81
|
+
|
|
82
|
+
## 第五阶段:apply 与复核
|
|
83
|
+
|
|
84
|
+
用户明确提供已审核 plan 后运行:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
<CLI> tidy apply --repo <REPO> [--branch <BRANCH>] --plan-file <PLAN>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
apply 会先备份,再按 reset → section → block → entry 的顺序执行。完成后必须读取生成的 `summary_*.md`,核对 accepted/rejected/custom、实际 rewritten/deleted 和 invalid actions。
|
|
91
|
+
|
|
92
|
+
custom proposal 不会自动 apply。根据反馈决定是直接精确改写、解释后再确认、保持不动,还是重新生成 proposals;不要机械重复整轮 prepare。
|
|
93
|
+
|
|
94
|
+
## 完成输出
|
|
95
|
+
|
|
96
|
+
最终向用户说明:
|
|
97
|
+
|
|
98
|
+
- unsorted 分类合并了多少条
|
|
99
|
+
- HTML 审核了多少个 proposal
|
|
100
|
+
- 实际改写、删除、重置的数量
|
|
101
|
+
- 备份和 summary 路径
|
|
102
|
+
- 因漂移校验而跳过的动作
|
|
103
|
+
|
package/lib/ui-app.html
CHANGED
|
@@ -75,6 +75,14 @@ button { font-family: inherit; }
|
|
|
75
75
|
.topbar .brand { display: flex; align-items: baseline; gap: 12px; min-width: 0; }
|
|
76
76
|
.topbar .title { font-weight: 600; font-size: 15px; }
|
|
77
77
|
.topbar .meta { color: var(--fg-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
78
|
+
.view-tabs { display: flex; gap: 4px; }
|
|
79
|
+
.view-tab { padding: 6px 10px; border: 1px solid var(--border); background: var(--bg-card); color: var(--fg); border-radius: var(--radius-sm); cursor: pointer; }
|
|
80
|
+
.view-tab.active { color: var(--accent-fg); background: var(--accent); border-color: var(--accent); }
|
|
81
|
+
.scan-table { width: 100%; border-collapse: collapse; background: var(--bg-card); border: 1px solid var(--border); font-size: 12px; }
|
|
82
|
+
.scan-table th, .scan-table td { padding: 9px 10px; text-align: left; border-bottom: 1px solid var(--border-subtle); vertical-align: top; }
|
|
83
|
+
.scan-table th { color: var(--fg-muted); background: var(--bg); font-weight: 600; }
|
|
84
|
+
.scan-table tr:last-child td { border-bottom: 0; }
|
|
85
|
+
.scan-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
78
86
|
|
|
79
87
|
.main { display: grid; grid-template-columns: 320px 1fr; overflow: hidden; min-height: 0; }
|
|
80
88
|
|
|
@@ -453,6 +461,10 @@ button.icon-btn:hover {
|
|
|
453
461
|
<span class="meta" id="meta">加载中…</span>
|
|
454
462
|
</div>
|
|
455
463
|
<div class="actions">
|
|
464
|
+
<span class="view-tabs">
|
|
465
|
+
<button class="view-tab active" id="memoryView">记忆</button>
|
|
466
|
+
<button class="view-tab" id="scanView">会话扫描</button>
|
|
467
|
+
</span>
|
|
456
468
|
<button class="icon-btn" id="reload" title="重新加载">↻ 刷新</button>
|
|
457
469
|
</div>
|
|
458
470
|
</div>
|
|
@@ -490,6 +502,8 @@ button.icon-btn:hover {
|
|
|
490
502
|
|
|
491
503
|
var state = {
|
|
492
504
|
tree: null,
|
|
505
|
+
scan: null,
|
|
506
|
+
view: "memory",
|
|
493
507
|
selectedRepoKey: null,
|
|
494
508
|
selectedBranch: null,
|
|
495
509
|
fileCache: new Map(),
|
|
@@ -672,6 +686,117 @@ function stat(label, value, small) {
|
|
|
672
686
|
);
|
|
673
687
|
}
|
|
674
688
|
|
|
689
|
+
function formatBytes(value) {
|
|
690
|
+
var n = Number(value || 0);
|
|
691
|
+
if (n < 1024) return n + " B";
|
|
692
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
693
|
+
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + " MB";
|
|
694
|
+
return (n / 1024 / 1024 / 1024).toFixed(2) + " GB";
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function renderScan() {
|
|
698
|
+
var data = state.scan;
|
|
699
|
+
var content = q("#content");
|
|
700
|
+
var sidebar = q(".sidebar");
|
|
701
|
+
sidebar.style.display = "none";
|
|
702
|
+
content.style.gridColumn = "1 / -1";
|
|
703
|
+
content.innerHTML = "";
|
|
704
|
+
if (!data || !data.exists) {
|
|
705
|
+
content.appendChild(el("div", { class: "placeholder" }, "暂无会话扫描记录"));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
content.appendChild(el("div", { class: "repo-header" }, el("h2", null, "会话扫描"), el("div", { class: "path mono" }, data.scanRoot)));
|
|
709
|
+
var totals = el("div", { class: "stat-row" });
|
|
710
|
+
totals.appendChild(stat("扫描运行", data.run_count));
|
|
711
|
+
totals.appendChild(stat("涉及仓库", data.repos.length));
|
|
712
|
+
totals.appendChild(stat("总结 Token", data.usage ? (data.usage.total_tokens || 0).toLocaleString() : "未上报"));
|
|
713
|
+
totals.appendChild(stat("Token 未上报调用", data.unavailable_invocations));
|
|
714
|
+
content.appendChild(totals);
|
|
715
|
+
|
|
716
|
+
content.appendChild(el("h3", { class: "section-title" }, "仓库统计"));
|
|
717
|
+
var repoTable = el("table", { class: "scan-table" });
|
|
718
|
+
repoTable.innerHTML = "<thead><tr><th>仓库 / 分支</th><th class='num'>扫描次数</th><th class='num'>会话</th><th class='num'>原始大小</th><th class='num'>新增数据</th><th class='num'>总结 Token</th><th>最近扫描</th></tr></thead>";
|
|
719
|
+
var repoBody = el("tbody");
|
|
720
|
+
data.repos.forEach(function (repo) {
|
|
721
|
+
repoBody.appendChild(el("tr", null,
|
|
722
|
+
el("td", null, el("div", { class: "mono" }, repo.repo_key), el("div", { class: "sub" }, repo.branches.join(", "))),
|
|
723
|
+
el("td", { class: "num" }, repo.scan_count),
|
|
724
|
+
el("td", { class: "num" }, repo.session_count),
|
|
725
|
+
el("td", { class: "num" }, formatBytes(repo.raw_bytes)),
|
|
726
|
+
el("td", { class: "num" }, formatBytes(repo.new_bytes)),
|
|
727
|
+
el("td", { class: "num" }, Number(repo.summary_tokens || 0).toLocaleString()),
|
|
728
|
+
el("td", null, formatDateTime(repo.last_scanned_at))
|
|
729
|
+
));
|
|
730
|
+
});
|
|
731
|
+
repoTable.appendChild(repoBody);
|
|
732
|
+
content.appendChild(repoTable);
|
|
733
|
+
|
|
734
|
+
content.appendChild(el("h3", { class: "section-title" }, "最近运行"));
|
|
735
|
+
var runTable = el("table", { class: "scan-table" });
|
|
736
|
+
runTable.innerHTML = "<thead><tr><th>时间 / Run ID</th><th>执行器</th><th class='num'>会话</th><th class='num'>新增数据</th><th class='num'>Token</th><th>结果</th></tr></thead>";
|
|
737
|
+
var runBody = el("tbody");
|
|
738
|
+
data.runs.forEach(function (run) {
|
|
739
|
+
var usage = run.summary_usage && run.summary_usage.total_tokens;
|
|
740
|
+
runBody.appendChild(el("tr", null,
|
|
741
|
+
el("td", null, el("div", null, formatDateTime(run.started_at)), el("div", { class: "mono" }, run.run_id)),
|
|
742
|
+
el("td", null, [run.executor || "-", run.model || ""].filter(Boolean).join(" / ")),
|
|
743
|
+
el("td", { class: "num" }, run.session_count),
|
|
744
|
+
el("td", { class: "num" }, formatBytes(run.new_bytes)),
|
|
745
|
+
el("td", { class: "num" }, usage == null ? "未上报" : Number(usage).toLocaleString()),
|
|
746
|
+
el("td", null, run.status ? run.status : (run.done_count + " 完成 · " + run.failed_count + " 失败 · " + run.skipped_count + " 跳过"))
|
|
747
|
+
));
|
|
748
|
+
});
|
|
749
|
+
runTable.appendChild(runBody);
|
|
750
|
+
content.appendChild(runTable);
|
|
751
|
+
|
|
752
|
+
var recentSessions = [];
|
|
753
|
+
data.runs.forEach(function (run) {
|
|
754
|
+
(run.sessions || []).forEach(function (session) {
|
|
755
|
+
recentSessions.push({ run_id: run.run_id, started_at: run.started_at, session: session });
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
recentSessions.sort(function (a, b) { return String(b.started_at || "").localeCompare(String(a.started_at || "")); });
|
|
759
|
+
recentSessions = recentSessions.slice(0, 200);
|
|
760
|
+
content.appendChild(el("h3", { class: "section-title" }, "最近会话"));
|
|
761
|
+
var sessionTable = el("table", { class: "scan-table" });
|
|
762
|
+
sessionTable.innerHTML = "<thead><tr><th>会话 / 仓库</th><th class='num'>原始大小</th><th class='num'>本次新增</th><th class='num'>原会话 Token</th><th class='num'>总结 Token</th><th>状态</th></tr></thead>";
|
|
763
|
+
var sessionBody = el("tbody");
|
|
764
|
+
recentSessions.forEach(function (row) {
|
|
765
|
+
var session = row.session;
|
|
766
|
+
sessionBody.appendChild(el("tr", null,
|
|
767
|
+
el("td", null,
|
|
768
|
+
el("div", { class: "mono" }, session.session_id || "-"),
|
|
769
|
+
el("div", null, [session.repo_key, session.branch].filter(Boolean).join(" / ")),
|
|
770
|
+
el("div", { class: "sub mono" }, session.path || "")
|
|
771
|
+
),
|
|
772
|
+
el("td", { class: "num" }, formatBytes(session.raw_size)),
|
|
773
|
+
el("td", { class: "num" }, formatBytes(session.new_bytes)),
|
|
774
|
+
el("td", { class: "num" }, session.session_usage && session.session_usage.total_tokens != null ? Number(session.session_usage.total_tokens).toLocaleString() : "未上报"),
|
|
775
|
+
el("td", { class: "num" }, session.summary_usage && session.summary_usage.total_tokens != null ? Number(session.summary_usage.total_tokens).toLocaleString() : "未上报"),
|
|
776
|
+
el("td", null, session.status + (session.reason ? " · " + session.reason : ""))
|
|
777
|
+
));
|
|
778
|
+
});
|
|
779
|
+
sessionTable.appendChild(sessionBody);
|
|
780
|
+
content.appendChild(sessionTable);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function setView(view) {
|
|
784
|
+
state.view = view;
|
|
785
|
+
q("#memoryView").classList.toggle("active", view === "memory");
|
|
786
|
+
q("#scanView").classList.toggle("active", view === "scan");
|
|
787
|
+
if (view === "scan") {
|
|
788
|
+
fetch("/api/session-scan").then(function (r) { return r.json(); }).then(function (data) {
|
|
789
|
+
state.scan = data;
|
|
790
|
+
q("#meta").textContent = data.run_count + " 次扫描 · " + data.repos.length + " 个仓库 · " + data.scanRoot;
|
|
791
|
+
renderScan();
|
|
792
|
+
}).catch(function (err) { q("#meta").textContent = "错误:" + err.message; });
|
|
793
|
+
} else {
|
|
794
|
+
q(".sidebar").style.display = "block";
|
|
795
|
+
q("#content").style.gridColumn = "";
|
|
796
|
+
loadTree();
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
675
800
|
function renderRepoList(repos, filterText) {
|
|
676
801
|
var list = q("#repoList");
|
|
677
802
|
list.innerHTML = "";
|
|
@@ -1068,8 +1193,10 @@ q("#search").addEventListener("input", function (e) {
|
|
|
1068
1193
|
});
|
|
1069
1194
|
q("#reload").addEventListener("click", function () {
|
|
1070
1195
|
state.fileCache.clear();
|
|
1071
|
-
|
|
1196
|
+
setView(state.view);
|
|
1072
1197
|
});
|
|
1198
|
+
q("#memoryView").addEventListener("click", function () { setView("memory"); });
|
|
1199
|
+
q("#scanView").addEventListener("click", function () { setView("scan"); });
|
|
1073
1200
|
q("#modalClose").addEventListener("click", closeModal);
|
|
1074
1201
|
q("#modalEdit").addEventListener("click", enterEditMode);
|
|
1075
1202
|
q("#modalSave").addEventListener("click", saveModalEdit);
|
package/lib/ui-server.js
CHANGED
|
@@ -13,6 +13,11 @@ function getStorageRoot() {
|
|
|
13
13
|
return process.env.DEV_ASSETS_ROOT || DEFAULT_STORAGE_ROOT;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function getScanRoot() {
|
|
17
|
+
return process.env.DEV_MEMORY_SCAN_ROOT
|
|
18
|
+
|| path.join(path.dirname(getStorageRoot()), "jobs", "session-scan");
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
function safeReadDir(dir) {
|
|
17
22
|
try {
|
|
18
23
|
return fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -135,6 +140,62 @@ function buildTree() {
|
|
|
135
140
|
return { storageRoot: root, exists: true, repos };
|
|
136
141
|
}
|
|
137
142
|
|
|
143
|
+
function buildSessionScanData() {
|
|
144
|
+
const root = getScanRoot();
|
|
145
|
+
const runsDir = path.join(root, "runs");
|
|
146
|
+
const runs = [];
|
|
147
|
+
for (const ent of safeReadDir(runsDir)) {
|
|
148
|
+
if (!ent.isFile() || !ent.name.endsWith(".json")) continue;
|
|
149
|
+
const value = safeReadJson(path.join(runsDir, ent.name));
|
|
150
|
+
if (value && typeof value === "object") runs.push(value);
|
|
151
|
+
}
|
|
152
|
+
runs.sort((a, b) => String(b.started_at || "").localeCompare(String(a.started_at || "")));
|
|
153
|
+
const repos = new Map();
|
|
154
|
+
const usage = {};
|
|
155
|
+
let unavailableInvocations = 0;
|
|
156
|
+
for (const run of runs) {
|
|
157
|
+
unavailableInvocations += Number(run.usage_unavailable_invocations || 0);
|
|
158
|
+
for (const [key, value] of Object.entries(run.summary_usage || {})) {
|
|
159
|
+
if (Number.isFinite(value)) usage[key] = (usage[key] || 0) + value;
|
|
160
|
+
}
|
|
161
|
+
for (const session of run.sessions || []) {
|
|
162
|
+
if (!session.repo_key) continue;
|
|
163
|
+
const row = repos.get(session.repo_key) || {
|
|
164
|
+
repo_key: session.repo_key,
|
|
165
|
+
branches: new Set(), sessions: new Set(), scan_count: 0,
|
|
166
|
+
raw_bytes: 0, new_bytes: 0, summary_tokens: 0, done: 0, failed: 0, last_scanned_at: null,
|
|
167
|
+
};
|
|
168
|
+
row.scan_count += 1;
|
|
169
|
+
row.sessions.add(session.session_id);
|
|
170
|
+
if (session.branch) row.branches.add(session.branch);
|
|
171
|
+
row.raw_bytes += Number(session.raw_size || 0);
|
|
172
|
+
row.new_bytes += Number(session.new_bytes || 0);
|
|
173
|
+
row.summary_tokens += Number((session.summary_usage || {}).total_tokens || 0);
|
|
174
|
+
if (session.status === "done") row.done += 1;
|
|
175
|
+
if (session.status === "failed") row.failed += 1;
|
|
176
|
+
if (!row.last_scanned_at || session.last_scanned_at > row.last_scanned_at) {
|
|
177
|
+
row.last_scanned_at = session.last_scanned_at;
|
|
178
|
+
}
|
|
179
|
+
repos.set(session.repo_key, row);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const repoRows = Array.from(repos.values()).map((row) => ({
|
|
183
|
+
...row,
|
|
184
|
+
branches: Array.from(row.branches).sort(),
|
|
185
|
+
session_count: row.sessions.size,
|
|
186
|
+
sessions: undefined,
|
|
187
|
+
})).sort((a, b) => String(b.last_scanned_at || "").localeCompare(String(a.last_scanned_at || "")));
|
|
188
|
+
return {
|
|
189
|
+
scanRoot: root,
|
|
190
|
+
exists: !!safeStat(root),
|
|
191
|
+
run_count: runs.length,
|
|
192
|
+
usage: Object.keys(usage).length ? usage : null,
|
|
193
|
+
unavailable_invocations: unavailableInvocations,
|
|
194
|
+
repos: repoRows,
|
|
195
|
+
runs: runs.slice(0, 100),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
138
199
|
function resolveSafePath(relPath) {
|
|
139
200
|
const root = getStorageRoot();
|
|
140
201
|
if (!relPath || typeof relPath !== "string") return null;
|
|
@@ -299,6 +360,11 @@ function start({ host = "127.0.0.1", port = 0, openBrowserFlag = true, readOnly
|
|
|
299
360
|
res.end(JSON.stringify(tree));
|
|
300
361
|
return;
|
|
301
362
|
}
|
|
363
|
+
if (url.pathname === "/api/session-scan" && isRead) {
|
|
364
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
365
|
+
res.end(JSON.stringify(buildSessionScanData()));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
302
368
|
if (url.pathname === "/api/injection-preview" && isRead) {
|
|
303
369
|
const repoKey = url.searchParams.get("repo") || "";
|
|
304
370
|
const branch = url.searchParams.get("branch") || "";
|
|
@@ -380,4 +446,4 @@ function start({ host = "127.0.0.1", port = 0, openBrowserFlag = true, readOnly
|
|
|
380
446
|
return server;
|
|
381
447
|
}
|
|
382
448
|
|
|
383
|
-
module.exports = { start, buildTree, getStorageRoot };
|
|
449
|
+
module.exports = { start, buildTree, buildSessionScanData, getStorageRoot, getScanRoot };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dev-memory-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "CLI for dev-memory hooks and repo-local setup (formerly @xluos/dev-assets-cli)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"hooks/*.json",
|
|
29
29
|
"hooks/README.md",
|
|
30
30
|
"lib/*.py",
|
|
31
|
+
"lib/maintenance/*.md",
|
|
31
32
|
"lib/ui-server.js",
|
|
32
33
|
"lib/ui-app.html",
|
|
33
34
|
"lib/assets/*",
|