dev-memory-cli 0.22.1 → 0.22.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.
- package/README.md +253 -292
- package/bin/dev-memory.js +8 -2
- package/hooks/README.md +170 -47
- package/hooks/codex-hooks.json +1 -1
- package/hooks/hooks.json +1 -1
- package/lib/dev_memory_capture.py +85 -7
- package/lib/dev_memory_common.py +40 -10
- package/lib/dev_memory_graduate.py +1 -1
- package/lib/dev_memory_session_scan.py +1052 -0
- package/lib/dev_memory_setup.py +8 -3
- package/lib/dev_memory_summary.py +25 -16
- package/lib/ui-app.html +128 -1
- package/lib/ui-server.js +67 -1
- package/package.json +1 -1
- package/scripts/hooks/_common.py +44 -7
- package/scripts/hooks/stop.py +6 -0
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",
|
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.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
package/scripts/hooks/_common.py
CHANGED
|
@@ -41,6 +41,7 @@ CONTEXT_SCRIPT = PACKAGE_ROOT / "lib" / "dev_memory_context.py"
|
|
|
41
41
|
CAPTURE_SCRIPT = PACKAGE_ROOT / "lib" / "dev_memory_capture.py"
|
|
42
42
|
SUMMARY_WORKER_SCRIPT = PACKAGE_ROOT / "scripts" / "hooks" / "session_summary_worker.py"
|
|
43
43
|
DEFAULT_CONFIG_PATH = Path(os.environ.get("DEV_MEMORY_CONFIG_PATH", "~/.dev-memory/config.json")).expanduser()
|
|
44
|
+
DEV_MEMORY_HOME = Path(os.environ.get("DEV_MEMORY_HOME", "~/.dev-memory")).expanduser()
|
|
44
45
|
WORKSPACE_CONFIG_NAMES = (".dev-memory-workspace.json", ".dev-assets-workspace.json")
|
|
45
46
|
|
|
46
47
|
|
|
@@ -121,6 +122,28 @@ def read_hook_input():
|
|
|
121
122
|
return {"raw": raw[:4000]}
|
|
122
123
|
|
|
123
124
|
|
|
125
|
+
def register_session_scan_candidate(hook_input):
|
|
126
|
+
"""Persist a lightweight Codex Stop hint without starting summarization."""
|
|
127
|
+
if not isinstance(hook_input, dict):
|
|
128
|
+
return None
|
|
129
|
+
session_id = hook_session_id(hook_input)
|
|
130
|
+
transcript_path = _hook_transcript_path(hook_input)
|
|
131
|
+
if not session_id and not transcript_path:
|
|
132
|
+
return None
|
|
133
|
+
source = session_id or transcript_path
|
|
134
|
+
digest = hashlib.sha1(str(source).encode("utf-8")).hexdigest()[:20]
|
|
135
|
+
path = DEV_MEMORY_HOME / "jobs" / "session-scan" / "candidates" / f"{digest}.json"
|
|
136
|
+
_atomic_write_json(path, {
|
|
137
|
+
"schema_version": 1,
|
|
138
|
+
"event": "Stop",
|
|
139
|
+
"registered_at": now_iso(),
|
|
140
|
+
"session_id": session_id,
|
|
141
|
+
"transcript_path": transcript_path,
|
|
142
|
+
"cwd": _first_string(hook_input.get("cwd"), str(REPO_ROOT)),
|
|
143
|
+
})
|
|
144
|
+
return path
|
|
145
|
+
|
|
146
|
+
|
|
124
147
|
def resolve_assets_for(repo_root):
|
|
125
148
|
"""Resolve asset paths for an explicit repo root (workspace-mode friendly)."""
|
|
126
149
|
repo_root_str = str(repo_root)
|
|
@@ -219,7 +242,7 @@ def extract_section(path, title):
|
|
|
219
242
|
return None if is_placeholder(body) else body
|
|
220
243
|
|
|
221
244
|
|
|
222
|
-
def extract_repo_file_body(path):
|
|
245
|
+
def extract_repo_file_body(path, *, newest_first=False):
|
|
223
246
|
"""Extract full body of a repo-level file, skipping the H1 title and
|
|
224
247
|
``## 仓库`` metadata section. Placeholder-only sections within the file
|
|
225
248
|
are dropped individually so that real content in sibling sections is
|
|
@@ -235,6 +258,11 @@ def extract_repo_file_body(path):
|
|
|
235
258
|
for sec in sections:
|
|
236
259
|
sec_body = strip_managed_markers(sec).strip()
|
|
237
260
|
if sec_body and not is_placeholder(sec_body):
|
|
261
|
+
if newest_first and sec_body.startswith("## "):
|
|
262
|
+
heading, separator, body = sec_body.partition("\n")
|
|
263
|
+
if separator and body.strip():
|
|
264
|
+
blocks = _split_recent_blocks(body)
|
|
265
|
+
sec_body = heading + "\n\n" + "\n\n".join(blocks)
|
|
238
266
|
kept.append(sec_body)
|
|
239
267
|
body = "\n\n".join(kept).strip()
|
|
240
268
|
return body or None
|
|
@@ -443,11 +471,14 @@ def _brief_section_keys(profile):
|
|
|
443
471
|
return tuple(_BRIEF_PROFILES.get(profile, _BRIEF_PROFILES["standard"]).keys())
|
|
444
472
|
|
|
445
473
|
|
|
446
|
-
def _extract_sections(paths, keys):
|
|
474
|
+
def _extract_sections(paths, keys, *, repo_newest_first=False):
|
|
447
475
|
out = []
|
|
448
476
|
for file_key, title in keys:
|
|
449
477
|
if title is None:
|
|
450
|
-
body = extract_repo_file_body(
|
|
478
|
+
body = extract_repo_file_body(
|
|
479
|
+
paths[file_key],
|
|
480
|
+
newest_first=repo_newest_first and file_key in {"repo_decisions", "repo_glossary"},
|
|
481
|
+
)
|
|
451
482
|
display_title = _REPO_DISPLAY_TITLES.get(file_key, paths[file_key].stem)
|
|
452
483
|
else:
|
|
453
484
|
body = extract_section(paths[file_key], title)
|
|
@@ -471,7 +502,7 @@ def _build_context_from_assets(assets, *, full=True, heading=None, brief_profile
|
|
|
471
502
|
|
|
472
503
|
paths = assets["paths"]
|
|
473
504
|
keys = _FULL_SECTION_KEYS if full else _brief_section_keys(brief_profile)
|
|
474
|
-
sections = _extract_sections(paths, keys)
|
|
505
|
+
sections = _extract_sections(paths, keys, repo_newest_first=full)
|
|
475
506
|
max_lines, max_chars = (8, 700) if full else (3, 200)
|
|
476
507
|
brief_limits = _BRIEF_PROFILES.get(brief_profile, _BRIEF_PROFILES["standard"])
|
|
477
508
|
|
|
@@ -898,9 +929,14 @@ def build_summary_input(job_path):
|
|
|
898
929
|
job = json.loads(Path(job_path).read_text(encoding="utf-8"))
|
|
899
930
|
return extract_core_payload(
|
|
900
931
|
job,
|
|
901
|
-
max_messages=_int_env("DEV_MEMORY_SESSION_SUMMARY_MAX_MESSAGES",
|
|
902
|
-
max_message_chars=_int_env("DEV_MEMORY_SESSION_SUMMARY_MAX_MESSAGE_CHARS",
|
|
932
|
+
max_messages=_int_env("DEV_MEMORY_SESSION_SUMMARY_MAX_MESSAGES", 0),
|
|
933
|
+
max_message_chars=_int_env("DEV_MEMORY_SESSION_SUMMARY_MAX_MESSAGE_CHARS", 0),
|
|
903
934
|
max_memory_chars=_int_env("DEV_MEMORY_SESSION_SUMMARY_MAX_MEMORY_CHARS", 4000),
|
|
935
|
+
since_size=(
|
|
936
|
+
((job.get("previous_job") or {}).get("processed") or {}).get("transcript_size", 0)
|
|
937
|
+
if isinstance(job.get("previous_job"), dict)
|
|
938
|
+
else 0
|
|
939
|
+
),
|
|
904
940
|
)
|
|
905
941
|
|
|
906
942
|
|
|
@@ -918,7 +954,8 @@ def build_summary_prompt(job_path, summary_input=None, summary_input_path=None):
|
|
|
918
954
|
summary_input = build_summary_input(job_path)
|
|
919
955
|
summary_input_json = json.dumps(summary_input, ensure_ascii=False, indent=2)
|
|
920
956
|
input_path_line = f"- summary input JSON: {summary_input_path}\n" if summary_input_path else ""
|
|
921
|
-
return f"""
|
|
957
|
+
return f"""DEV_MEMORY_INTERNAL_SESSION_SUMMARY_V1
|
|
958
|
+
你是 dev-memory 的后台会话总结 worker。
|
|
922
959
|
|
|
923
960
|
输入:
|
|
924
961
|
- job JSON: {job_path}
|
package/scripts/hooks/stop.py
CHANGED
|
@@ -5,13 +5,19 @@ from _common import (
|
|
|
5
5
|
is_workspace_mode,
|
|
6
6
|
log,
|
|
7
7
|
maybe_record_head,
|
|
8
|
+
read_hook_input,
|
|
8
9
|
record_head_all_repos,
|
|
10
|
+
register_session_scan_candidate,
|
|
9
11
|
resolve_assets,
|
|
10
12
|
)
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
def main():
|
|
16
|
+
hook_input = read_hook_input()
|
|
14
17
|
try:
|
|
18
|
+
candidate = register_session_scan_candidate(hook_input)
|
|
19
|
+
if candidate:
|
|
20
|
+
log(f"[dev-memory][Stop] registered session scan candidate {candidate}")
|
|
15
21
|
if is_no_git_mode():
|
|
16
22
|
log("[dev-memory][Stop] no-git mode: nothing to record (no HEAD)")
|
|
17
23
|
return 0
|