dev-memory-cli 0.18.2 → 0.19.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 +45 -8
- package/bin/dev-memory.js +79 -1
- package/hooks/README.md +8 -2
- package/hooks/hooks.json +1 -1
- package/lib/dev_memory_capture.py +637 -84
- package/lib/dev_memory_common.py +129 -71
- package/lib/dev_memory_context.py +35 -1
- package/lib/dev_memory_graduate.py +83 -20
- package/lib/dev_memory_read.py +317 -0
- package/lib/dev_memory_setup.py +0 -2
- package/lib/dev_memory_summary.py +233 -0
- package/lib/ui-app.html +41 -0
- package/lib/ui-server.js +53 -0
- package/package.json +1 -1
- package/scripts/hooks/_common.py +670 -15
- package/scripts/hooks/session_end.py +14 -0
- package/scripts/hooks/session_start.py +17 -1
- package/suite-manifest.json +1 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
10
|
+
|
|
11
|
+
from dev_memory_common import ( # noqa: E402
|
|
12
|
+
DEV_MEMORY_ID_FILE,
|
|
13
|
+
LEGACY_ID_FILE,
|
|
14
|
+
MANAGED_FILES,
|
|
15
|
+
asset_paths,
|
|
16
|
+
detect_no_git_mode,
|
|
17
|
+
get_branch_paths,
|
|
18
|
+
read_json,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
BRANCH_FILE_KEYS = (
|
|
23
|
+
"overview",
|
|
24
|
+
"progress",
|
|
25
|
+
"decisions",
|
|
26
|
+
"risks",
|
|
27
|
+
"glossary",
|
|
28
|
+
"unsorted",
|
|
29
|
+
"pending_promotion",
|
|
30
|
+
"log",
|
|
31
|
+
"manifest",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
REPO_FILE_KEYS = (
|
|
35
|
+
"repo_overview",
|
|
36
|
+
"repo_decisions",
|
|
37
|
+
"repo_glossary",
|
|
38
|
+
"repo_log",
|
|
39
|
+
"repo_manifest",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
READ_ORDER = (
|
|
43
|
+
"glossary",
|
|
44
|
+
"decisions",
|
|
45
|
+
"risks",
|
|
46
|
+
"overview",
|
|
47
|
+
"repo_decisions",
|
|
48
|
+
"repo_glossary",
|
|
49
|
+
"repo_overview",
|
|
50
|
+
"unsorted",
|
|
51
|
+
"pending_promotion",
|
|
52
|
+
"log",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_paths(repo, context_dir=None, branch=None):
|
|
57
|
+
repo_path = Path(repo).expanduser().resolve()
|
|
58
|
+
if detect_no_git_mode(repo_path) and not (
|
|
59
|
+
(repo_path / DEV_MEMORY_ID_FILE).exists()
|
|
60
|
+
or (repo_path / LEGACY_ID_FILE).exists()
|
|
61
|
+
):
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"no-git memory is not initialized; read refuses to create a .dev-memory-id. "
|
|
64
|
+
"Initialize by writing via capture/setup first, or pass a Git repo path."
|
|
65
|
+
)
|
|
66
|
+
repo_root, branch_name, branch_key, storage_root, repo_key, repo_dir, branch_dir = get_branch_paths(
|
|
67
|
+
repo, context_dir, branch
|
|
68
|
+
)
|
|
69
|
+
paths = asset_paths(repo_dir, branch_dir)
|
|
70
|
+
return {
|
|
71
|
+
"repo_root": repo_root,
|
|
72
|
+
"branch_name": branch_name,
|
|
73
|
+
"branch_key": branch_key,
|
|
74
|
+
"storage_root": storage_root,
|
|
75
|
+
"repo_key": repo_key,
|
|
76
|
+
"repo_dir": repo_dir,
|
|
77
|
+
"branch_dir": branch_dir,
|
|
78
|
+
"paths": paths,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _existing_branch_dirs(repo_dir):
|
|
83
|
+
branches_dir = repo_dir / "branches"
|
|
84
|
+
if not branches_dir.exists():
|
|
85
|
+
return []
|
|
86
|
+
result = []
|
|
87
|
+
for path in sorted(branches_dir.iterdir(), key=lambda p: p.name):
|
|
88
|
+
if not path.is_dir() or path.name == "_archived":
|
|
89
|
+
continue
|
|
90
|
+
manifest = read_json(path / "manifest.json")
|
|
91
|
+
result.append(
|
|
92
|
+
{
|
|
93
|
+
"branch_key": path.name,
|
|
94
|
+
"branch": manifest.get("branch") or path.name.replace("__", "/"),
|
|
95
|
+
"path": str(path),
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _existing_files(paths, keys):
|
|
102
|
+
files = {}
|
|
103
|
+
for key in keys:
|
|
104
|
+
path = paths[key]
|
|
105
|
+
files[key] = {
|
|
106
|
+
"path": str(path),
|
|
107
|
+
"exists": path.exists(),
|
|
108
|
+
}
|
|
109
|
+
return files
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _memory_files_for_dir(memory_dir):
|
|
113
|
+
for file_name in MANAGED_FILES:
|
|
114
|
+
path = memory_dir / file_name
|
|
115
|
+
if path.exists() and path.is_file():
|
|
116
|
+
yield path
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _dedupe_paths(paths):
|
|
120
|
+
seen = set()
|
|
121
|
+
result = []
|
|
122
|
+
for path in paths:
|
|
123
|
+
resolved = path.resolve()
|
|
124
|
+
if resolved in seen:
|
|
125
|
+
continue
|
|
126
|
+
seen.add(resolved)
|
|
127
|
+
result.append(path)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _scope_files(resolved, scope):
|
|
132
|
+
paths = resolved["paths"]
|
|
133
|
+
repo_dir = resolved["repo_dir"]
|
|
134
|
+
branch_dir = resolved["branch_dir"]
|
|
135
|
+
files = []
|
|
136
|
+
|
|
137
|
+
if scope in ("branch", "current"):
|
|
138
|
+
files.extend(paths[key] for key in BRANCH_FILE_KEYS)
|
|
139
|
+
if scope in ("repo", "current"):
|
|
140
|
+
files.extend(paths[key] for key in REPO_FILE_KEYS)
|
|
141
|
+
if scope == "all-branches":
|
|
142
|
+
files.extend(paths[key] for key in REPO_FILE_KEYS)
|
|
143
|
+
branches_dir = repo_dir / "branches"
|
|
144
|
+
if branches_dir.exists():
|
|
145
|
+
for child in sorted(branches_dir.iterdir(), key=lambda p: p.name):
|
|
146
|
+
if child.is_dir() and child.name != "_archived":
|
|
147
|
+
files.extend(_memory_files_for_dir(child))
|
|
148
|
+
if scope == "archived":
|
|
149
|
+
archived_dir = repo_dir / "branches" / "_archived"
|
|
150
|
+
if archived_dir.exists():
|
|
151
|
+
for child in sorted(archived_dir.iterdir(), key=lambda p: p.name):
|
|
152
|
+
if child.is_dir():
|
|
153
|
+
files.extend(_memory_files_for_dir(child))
|
|
154
|
+
if scope == "all":
|
|
155
|
+
files.extend(paths[key] for key in REPO_FILE_KEYS)
|
|
156
|
+
branches_dir = repo_dir / "branches"
|
|
157
|
+
if branches_dir.exists():
|
|
158
|
+
for child in sorted(branches_dir.rglob("*"), key=lambda p: p.as_posix()):
|
|
159
|
+
if child.is_dir() and child.name != "_archived":
|
|
160
|
+
files.extend(_memory_files_for_dir(child))
|
|
161
|
+
|
|
162
|
+
return [p for p in _dedupe_paths(files) if p.exists() and p.is_file()]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _make_matchers(queries, *, regex=False, case_sensitive=False):
|
|
166
|
+
if not queries:
|
|
167
|
+
raise ValueError("pass at least one --query")
|
|
168
|
+
if regex:
|
|
169
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
170
|
+
return [(q, re.compile(q, flags)) for q in queries]
|
|
171
|
+
if case_sensitive:
|
|
172
|
+
return [(q, q) for q in queries]
|
|
173
|
+
return [(q, q.lower()) for q in queries]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _line_matches(line, matchers, *, regex=False, case_sensitive=False):
|
|
177
|
+
haystack = line if case_sensitive else line.lower()
|
|
178
|
+
matched = []
|
|
179
|
+
for raw, matcher in matchers:
|
|
180
|
+
if regex:
|
|
181
|
+
if matcher.search(line):
|
|
182
|
+
matched.append(raw)
|
|
183
|
+
elif matcher in haystack:
|
|
184
|
+
matched.append(raw)
|
|
185
|
+
return matched
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _context_lines(lines, line_no, radius):
|
|
189
|
+
if radius <= 0:
|
|
190
|
+
return []
|
|
191
|
+
start = max(1, line_no - radius)
|
|
192
|
+
end = min(len(lines), line_no + radius)
|
|
193
|
+
result = []
|
|
194
|
+
for current in range(start, end + 1):
|
|
195
|
+
if current == line_no:
|
|
196
|
+
continue
|
|
197
|
+
result.append({"line": current, "text": lines[current - 1]})
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def command_show(args):
|
|
202
|
+
resolved = _resolve_paths(args.repo, args.context_dir, args.branch)
|
|
203
|
+
paths = resolved["paths"]
|
|
204
|
+
payload = {
|
|
205
|
+
"repo_root": str(resolved["repo_root"]),
|
|
206
|
+
"repo_key": resolved["repo_key"],
|
|
207
|
+
"branch": resolved["branch_name"],
|
|
208
|
+
"branch_key": resolved["branch_key"],
|
|
209
|
+
"storage_root": str(resolved["storage_root"]),
|
|
210
|
+
"repo_dir": str(resolved["repo_dir"]),
|
|
211
|
+
"branch_dir": str(resolved["branch_dir"]),
|
|
212
|
+
"repo_exists": resolved["repo_dir"].exists(),
|
|
213
|
+
"branch_exists": resolved["branch_dir"].exists(),
|
|
214
|
+
"recommended_read_order": [
|
|
215
|
+
{"key": key, "path": str(paths[key]), "exists": paths[key].exists()}
|
|
216
|
+
for key in READ_ORDER
|
|
217
|
+
if key in paths
|
|
218
|
+
],
|
|
219
|
+
"branch_files": _existing_files(paths, BRANCH_FILE_KEYS),
|
|
220
|
+
"repo_files": _existing_files(paths, REPO_FILE_KEYS),
|
|
221
|
+
"existing_branches": _existing_branch_dirs(resolved["repo_dir"]),
|
|
222
|
+
}
|
|
223
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def command_search(args):
|
|
227
|
+
resolved = _resolve_paths(args.repo, args.context_dir, args.branch)
|
|
228
|
+
matchers = _make_matchers(args.query, regex=args.regex, case_sensitive=args.case_sensitive)
|
|
229
|
+
files = _scope_files(resolved, args.scope)
|
|
230
|
+
hits = []
|
|
231
|
+
|
|
232
|
+
for path in files:
|
|
233
|
+
try:
|
|
234
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
235
|
+
except UnicodeDecodeError:
|
|
236
|
+
continue
|
|
237
|
+
for idx, line in enumerate(lines, start=1):
|
|
238
|
+
matched_queries = _line_matches(
|
|
239
|
+
line,
|
|
240
|
+
matchers,
|
|
241
|
+
regex=args.regex,
|
|
242
|
+
case_sensitive=args.case_sensitive,
|
|
243
|
+
)
|
|
244
|
+
if not matched_queries:
|
|
245
|
+
continue
|
|
246
|
+
hits.append(
|
|
247
|
+
{
|
|
248
|
+
"path": str(path),
|
|
249
|
+
"line": idx,
|
|
250
|
+
"text": line,
|
|
251
|
+
"matched_queries": matched_queries,
|
|
252
|
+
"context": _context_lines(lines, idx, args.context_lines),
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
if len(hits) >= args.max_hits:
|
|
256
|
+
break
|
|
257
|
+
if len(hits) >= args.max_hits:
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
payload = {
|
|
261
|
+
"repo_root": str(resolved["repo_root"]),
|
|
262
|
+
"repo_key": resolved["repo_key"],
|
|
263
|
+
"branch": resolved["branch_name"],
|
|
264
|
+
"branch_key": resolved["branch_key"],
|
|
265
|
+
"repo_dir": str(resolved["repo_dir"]),
|
|
266
|
+
"branch_dir": str(resolved["branch_dir"]),
|
|
267
|
+
"scope": args.scope,
|
|
268
|
+
"queries": args.query,
|
|
269
|
+
"regex": args.regex,
|
|
270
|
+
"searched_files": [str(p) for p in files],
|
|
271
|
+
"hit_count": len(hits),
|
|
272
|
+
"hits": hits,
|
|
273
|
+
}
|
|
274
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def main():
|
|
278
|
+
parser = argparse.ArgumentParser(
|
|
279
|
+
description="Locate and search dev-memory files for the current repo/branch without scanning global memory."
|
|
280
|
+
)
|
|
281
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
282
|
+
|
|
283
|
+
show = subparsers.add_parser("show", help="Show authoritative memory paths for a repo/branch.")
|
|
284
|
+
show.add_argument("--repo", default=".", help="Path inside the target Git repository")
|
|
285
|
+
show.add_argument("--context-dir", help="User-home storage root. Defaults to ~/.dev-memory/repos")
|
|
286
|
+
show.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
|
|
287
|
+
|
|
288
|
+
search = subparsers.add_parser("search", help="Search memory files under the resolved repo memory directory.")
|
|
289
|
+
search.add_argument("--repo", default=".", help="Path inside the target Git repository")
|
|
290
|
+
search.add_argument("--context-dir", help="User-home storage root. Defaults to ~/.dev-memory/repos")
|
|
291
|
+
search.add_argument("--branch", help="Branch name. Defaults to the current checked-out branch")
|
|
292
|
+
search.add_argument(
|
|
293
|
+
"--scope",
|
|
294
|
+
choices=("current", "branch", "repo", "all-branches", "archived", "all"),
|
|
295
|
+
default="current",
|
|
296
|
+
help="Which memory files to search. Defaults to current branch + repo shared layer.",
|
|
297
|
+
)
|
|
298
|
+
search.add_argument("--query", action="append", required=True, help="Literal query. Repeat for OR matching.")
|
|
299
|
+
search.add_argument("--regex", action="store_true", help="Treat --query values as Python regex patterns.")
|
|
300
|
+
search.add_argument("--case-sensitive", action="store_true")
|
|
301
|
+
search.add_argument("--context-lines", type=int, default=1, help="Neighboring lines to include around each hit.")
|
|
302
|
+
search.add_argument("--max-hits", type=int, default=80)
|
|
303
|
+
|
|
304
|
+
args = parser.parse_args()
|
|
305
|
+
try:
|
|
306
|
+
if args.command == "show":
|
|
307
|
+
command_show(args)
|
|
308
|
+
else:
|
|
309
|
+
command_search(args)
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
312
|
+
return 1
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
raise SystemExit(main())
|
package/lib/dev_memory_setup.py
CHANGED
|
@@ -102,8 +102,6 @@ def command_init(args):
|
|
|
102
102
|
|
|
103
103
|
_SETUP_KIND_TO_TARGET = {
|
|
104
104
|
"decision": ("decisions", "关键决策与原因"),
|
|
105
|
-
"progress": ("progress", "当前进展"),
|
|
106
|
-
"next": ("progress", "下一步"),
|
|
107
105
|
"risk": ("risks", "阻塞与注意点"),
|
|
108
106
|
"glossary": ("glossary", "当前有效上下文"),
|
|
109
107
|
"source": ("glossary", "分支源资料入口"),
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _read_json(path):
|
|
10
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _text_from_content(content):
|
|
14
|
+
if isinstance(content, str):
|
|
15
|
+
return content
|
|
16
|
+
if not isinstance(content, list):
|
|
17
|
+
return ""
|
|
18
|
+
parts = []
|
|
19
|
+
for item in content:
|
|
20
|
+
if not isinstance(item, dict):
|
|
21
|
+
continue
|
|
22
|
+
typ = item.get("type")
|
|
23
|
+
if typ in ("tool_use", "tool_result", "function_call", "function_call_output"):
|
|
24
|
+
continue
|
|
25
|
+
text = item.get("text")
|
|
26
|
+
if isinstance(text, str):
|
|
27
|
+
parts.append(text)
|
|
28
|
+
return "\n".join(parts).strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _extract_claude(obj):
|
|
32
|
+
typ = obj.get("type")
|
|
33
|
+
if typ not in ("user", "assistant"):
|
|
34
|
+
return None
|
|
35
|
+
msg = obj.get("message") if isinstance(obj.get("message"), dict) else {}
|
|
36
|
+
role = msg.get("role") or typ
|
|
37
|
+
text = _text_from_content(msg.get("content"))
|
|
38
|
+
if not text:
|
|
39
|
+
return None
|
|
40
|
+
return {
|
|
41
|
+
"source": "claude",
|
|
42
|
+
"role": role,
|
|
43
|
+
"timestamp": obj.get("timestamp"),
|
|
44
|
+
"uuid": obj.get("uuid"),
|
|
45
|
+
"text": text,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_codex(obj):
|
|
50
|
+
if obj.get("type") != "response_item":
|
|
51
|
+
return None
|
|
52
|
+
payload = obj.get("payload")
|
|
53
|
+
if not isinstance(payload, dict):
|
|
54
|
+
return None
|
|
55
|
+
if payload.get("type") != "message":
|
|
56
|
+
return None
|
|
57
|
+
role = payload.get("role")
|
|
58
|
+
if role not in ("user", "assistant"):
|
|
59
|
+
return None
|
|
60
|
+
text = _text_from_content(payload.get("content"))
|
|
61
|
+
if not text:
|
|
62
|
+
return None
|
|
63
|
+
return {
|
|
64
|
+
"source": "codex",
|
|
65
|
+
"role": role,
|
|
66
|
+
"timestamp": obj.get("timestamp"),
|
|
67
|
+
"text": text,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _iter_core_messages(transcript_path):
|
|
72
|
+
if not transcript_path:
|
|
73
|
+
return []
|
|
74
|
+
path = Path(transcript_path).expanduser()
|
|
75
|
+
if not path.exists():
|
|
76
|
+
return []
|
|
77
|
+
out = []
|
|
78
|
+
with path.open(encoding="utf-8") as f:
|
|
79
|
+
for line in f:
|
|
80
|
+
line = line.strip()
|
|
81
|
+
if not line:
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
obj = json.loads(line)
|
|
85
|
+
except Exception:
|
|
86
|
+
continue
|
|
87
|
+
msg = _extract_claude(obj) or _extract_codex(obj)
|
|
88
|
+
if msg:
|
|
89
|
+
out.append(msg)
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _truncate(text, max_chars):
|
|
94
|
+
text = (text or "").strip()
|
|
95
|
+
if len(text) <= max_chars:
|
|
96
|
+
return text
|
|
97
|
+
return text[: max_chars - 3].rstrip() + "..."
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_nonsemantic_user_text(text):
|
|
101
|
+
stripped = (text or "").strip()
|
|
102
|
+
if not stripped:
|
|
103
|
+
return True
|
|
104
|
+
markers = (
|
|
105
|
+
"<local-command-caveat>",
|
|
106
|
+
"<command-name>",
|
|
107
|
+
"<command-message>",
|
|
108
|
+
"<command-args>",
|
|
109
|
+
"Your tool call was malformed",
|
|
110
|
+
)
|
|
111
|
+
return any(marker in stripped for marker in markers)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _memory_file(path, max_chars, name=None):
|
|
115
|
+
p = Path(path)
|
|
116
|
+
if not p.exists():
|
|
117
|
+
return None
|
|
118
|
+
text = p.read_text(encoding="utf-8")
|
|
119
|
+
item = {
|
|
120
|
+
"name": name or p.name,
|
|
121
|
+
"content": _truncate(text, max_chars),
|
|
122
|
+
}
|
|
123
|
+
if len(text) > max_chars:
|
|
124
|
+
item["truncated"] = True
|
|
125
|
+
return item
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _summary_job(job):
|
|
129
|
+
transcript_state = job.get("transcript_state") if isinstance(job.get("transcript_state"), dict) else {}
|
|
130
|
+
previous_job = job.get("previous_job") if isinstance(job.get("previous_job"), dict) else {}
|
|
131
|
+
return {
|
|
132
|
+
"repo_root": job.get("repo_root"),
|
|
133
|
+
"transcript_state": {
|
|
134
|
+
key: transcript_state.get(key)
|
|
135
|
+
for key in ("size", "mtime_ms")
|
|
136
|
+
if transcript_state.get(key) is not None
|
|
137
|
+
},
|
|
138
|
+
"previous_processed": previous_job.get("processed") if isinstance(previous_job.get("processed"), dict) else None,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def extract_core_payload(
|
|
143
|
+
job,
|
|
144
|
+
*,
|
|
145
|
+
max_messages=40,
|
|
146
|
+
max_message_chars=2000,
|
|
147
|
+
max_memory_chars=6000,
|
|
148
|
+
since_size=0,
|
|
149
|
+
include_message_metadata=False,
|
|
150
|
+
):
|
|
151
|
+
branch_dir = Path(job["branch_dir"])
|
|
152
|
+
repo_dir = Path(job["repo_dir"])
|
|
153
|
+
memory_paths = [
|
|
154
|
+
("branch/progress.md", branch_dir / "progress.md"),
|
|
155
|
+
("branch/risks.md", branch_dir / "risks.md"),
|
|
156
|
+
("branch/decisions.md", branch_dir / "decisions.md"),
|
|
157
|
+
("branch/glossary.md", branch_dir / "glossary.md"),
|
|
158
|
+
("branch/overview.md", branch_dir / "overview.md"),
|
|
159
|
+
("repo/decisions.md", repo_dir / "repo" / "decisions.md"),
|
|
160
|
+
("repo/glossary.md", repo_dir / "repo" / "glossary.md"),
|
|
161
|
+
]
|
|
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
|
|
168
|
+
messages = [m for m in messages if not _is_nonsemantic_user_text(m["text"])]
|
|
169
|
+
recent = messages[-max_messages:]
|
|
170
|
+
core_messages = []
|
|
171
|
+
for m in recent:
|
|
172
|
+
item = {
|
|
173
|
+
"role": m["role"],
|
|
174
|
+
"text": _truncate(m["text"], max_message_chars),
|
|
175
|
+
}
|
|
176
|
+
if include_message_metadata:
|
|
177
|
+
item = {**m, "text": item["text"]}
|
|
178
|
+
core_messages.append(item)
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
"job": _summary_job(job),
|
|
182
|
+
"existing_memory": [
|
|
183
|
+
item
|
|
184
|
+
for item in (
|
|
185
|
+
_memory_file(path, max_memory_chars, name=name)
|
|
186
|
+
for name, path in memory_paths
|
|
187
|
+
)
|
|
188
|
+
if item is not None
|
|
189
|
+
],
|
|
190
|
+
"core_messages": core_messages,
|
|
191
|
+
"stats": {
|
|
192
|
+
"core_message_count": len(messages),
|
|
193
|
+
"returned_core_message_count": len(recent),
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def command_extract_core(args):
|
|
199
|
+
job = _read_json(args.job)
|
|
200
|
+
payload = extract_core_payload(
|
|
201
|
+
job,
|
|
202
|
+
max_messages=args.max_messages,
|
|
203
|
+
max_message_chars=args.max_message_chars,
|
|
204
|
+
max_memory_chars=args.max_memory_chars,
|
|
205
|
+
since_size=args.since_size,
|
|
206
|
+
include_message_metadata=args.include_message_metadata,
|
|
207
|
+
)
|
|
208
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main():
|
|
212
|
+
parser = argparse.ArgumentParser(description="Session summary helper commands.")
|
|
213
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
214
|
+
p = sub.add_parser("extract-core", help="Extract core transcript messages and current memory for a summary job")
|
|
215
|
+
p.add_argument("job", help="Path to a session-summary pending job JSON")
|
|
216
|
+
p.add_argument("--max-messages", type=int, default=40)
|
|
217
|
+
p.add_argument("--max-message-chars", type=int, default=2000)
|
|
218
|
+
p.add_argument("--max-memory-chars", type=int, default=6000)
|
|
219
|
+
p.add_argument("--since-size", type=int, default=0, help="Reserved cursor hint for future byte-offset extraction")
|
|
220
|
+
p.add_argument(
|
|
221
|
+
"--include-message-metadata",
|
|
222
|
+
action="store_true",
|
|
223
|
+
help="Include source/timestamp/uuid fields in core_messages for debugging",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
args = parser.parse_args()
|
|
227
|
+
if args.command == "extract-core":
|
|
228
|
+
command_extract_core(args)
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
raise SystemExit(main())
|
package/lib/ui-app.html
CHANGED
|
@@ -840,6 +840,10 @@ function renderContent() {
|
|
|
840
840
|
content.appendChild(bs);
|
|
841
841
|
}
|
|
842
842
|
|
|
843
|
+
var previewBtn = el("button", { class: "icon-btn", style: "margin-bottom: 16px;" }, "👁 注入预览");
|
|
844
|
+
previewBtn.onclick = function () { openInjectionPreview(repo.key, cur.name); };
|
|
845
|
+
content.appendChild(previewBtn);
|
|
846
|
+
|
|
843
847
|
var branchMdFiles = cur.files.filter(function (f) { return f.name.endsWith(".md"); });
|
|
844
848
|
if (branchMdFiles.length === 0) {
|
|
845
849
|
content.appendChild(el("div", { class: "empty-state" }, "该分支暂无记忆文件"));
|
|
@@ -989,6 +993,43 @@ function closeModal() {
|
|
|
989
993
|
modalState.editing = false;
|
|
990
994
|
}
|
|
991
995
|
|
|
996
|
+
function openInjectionPreview(repoKey, branchName) {
|
|
997
|
+
modalState.relPath = null;
|
|
998
|
+
modalState.text = "";
|
|
999
|
+
modalState.editing = false;
|
|
1000
|
+
q("#modalTitle").textContent = "注入预览 — " + branchName;
|
|
1001
|
+
q("#modalEdit").hidden = true;
|
|
1002
|
+
q("#modalSave").hidden = true;
|
|
1003
|
+
q("#modalCancel").hidden = true;
|
|
1004
|
+
setModalStatus("");
|
|
1005
|
+
var body = q("#modalBody");
|
|
1006
|
+
body.innerHTML = "";
|
|
1007
|
+
body.appendChild(el("span", { class: "loading" }, "生成注入预览…"));
|
|
1008
|
+
q("#modal").classList.add("open");
|
|
1009
|
+
|
|
1010
|
+
fetch("/api/injection-preview?repo=" + encodeURIComponent(repoKey) + "&branch=" + encodeURIComponent(branchName))
|
|
1011
|
+
.then(function (r) { return r.json(); })
|
|
1012
|
+
.then(function (data) {
|
|
1013
|
+
body.innerHTML = "";
|
|
1014
|
+
if (data.error) {
|
|
1015
|
+
body.appendChild(el("pre", { class: "raw" }, "错误:" + data.error));
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
var text = data.context || "(空)";
|
|
1019
|
+
var pre = document.createElement("pre");
|
|
1020
|
+
pre.className = "raw";
|
|
1021
|
+
pre.style.whiteSpace = "pre-wrap";
|
|
1022
|
+
pre.style.wordBreak = "break-word";
|
|
1023
|
+
pre.textContent = text;
|
|
1024
|
+
body.appendChild(pre);
|
|
1025
|
+
setModalStatus(text.length + " 字符", "success");
|
|
1026
|
+
})
|
|
1027
|
+
.catch(function (err) {
|
|
1028
|
+
body.innerHTML = "";
|
|
1029
|
+
body.appendChild(el("pre", { class: "raw" }, "请求失败:" + err.message));
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
|
|
992
1033
|
function loadTree() {
|
|
993
1034
|
q("#meta").textContent = "加载中…";
|
|
994
1035
|
q("#repoList").innerHTML = '<div class="loading">加载中…</div>';
|
package/lib/ui-server.js
CHANGED
|
@@ -201,6 +201,42 @@ function writeFileSafely(fullPath, content) {
|
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
const CONTEXT_SCRIPT = path.join(__dirname, "dev_memory_context.py");
|
|
205
|
+
|
|
206
|
+
function runInjectionPreview(repoKey, branch) {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
const args = [
|
|
209
|
+
CONTEXT_SCRIPT,
|
|
210
|
+
"injection-preview",
|
|
211
|
+
"--repo-key", repoKey,
|
|
212
|
+
"--branch", branch,
|
|
213
|
+
"--context-dir", getStorageRoot(),
|
|
214
|
+
];
|
|
215
|
+
const child = spawn(process.env.DEV_MEMORY_PYTHON || "python3", args, {
|
|
216
|
+
cwd: __dirname,
|
|
217
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
218
|
+
timeout: 15000,
|
|
219
|
+
});
|
|
220
|
+
const chunks = [];
|
|
221
|
+
child.stdout.on("data", (d) => chunks.push(d));
|
|
222
|
+
let stderr = "";
|
|
223
|
+
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
224
|
+
child.on("close", (code) => {
|
|
225
|
+
const stdout = Buffer.concat(chunks).toString("utf8");
|
|
226
|
+
if (code !== 0) {
|
|
227
|
+
reject(new Error(stderr || `exit ${code}`));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
resolve(JSON.parse(stdout));
|
|
232
|
+
} catch (e) {
|
|
233
|
+
reject(new Error(`invalid JSON: ${e.message}`));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
child.on("error", reject);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
204
240
|
function readAppHtml() {
|
|
205
241
|
return fs.readFileSync(APP_HTML_PATH, "utf8");
|
|
206
242
|
}
|
|
@@ -263,6 +299,23 @@ function start({ host = "127.0.0.1", port = 0, openBrowserFlag = true, readOnly
|
|
|
263
299
|
res.end(JSON.stringify(tree));
|
|
264
300
|
return;
|
|
265
301
|
}
|
|
302
|
+
if (url.pathname === "/api/injection-preview" && isRead) {
|
|
303
|
+
const repoKey = url.searchParams.get("repo") || "";
|
|
304
|
+
const branch = url.searchParams.get("branch") || "";
|
|
305
|
+
if (!repoKey || !branch) {
|
|
306
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
307
|
+
res.end(JSON.stringify({ error: "repo and branch required" }));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
runInjectionPreview(repoKey, branch).then((data) => {
|
|
311
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
312
|
+
res.end(JSON.stringify(data));
|
|
313
|
+
}).catch((err) => {
|
|
314
|
+
res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
|
|
315
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
316
|
+
});
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
266
319
|
if (url.pathname === "/api/file" && isRead) {
|
|
267
320
|
const rel = url.searchParams.get("path") || "";
|
|
268
321
|
const full = resolveSafePath(rel);
|