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.
@@ -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())
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dev-memory-cli",
3
- "version": "0.18.2",
3
+ "version": "0.19.2",
4
4
  "description": "CLI for dev-memory hooks and repo-local setup (formerly @xluos/dev-assets-cli)",
5
5
  "license": "MIT",
6
6
  "repository": {