codexmate 0.0.49 → 0.0.51

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.
@@ -244,9 +244,6 @@ function createAgentsFileController(deps = {}) {
244
244
  if (context === 'claude-md') {
245
245
  readResult = readClaudeMdFile({ metaOnly });
246
246
  } else if (context === 'claude-project') {
247
- if (!params.baseDir || !String(params.baseDir).trim()) {
248
- return { error: 'project path is required for claude-project context' };
249
- }
250
247
  readResult = readClaudeMdFile({ ...params, metaOnly });
251
248
  } else if (context === 'openclaw') {
252
249
  readResult = readOpenclawAgentsFile({ metaOnly });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexmate",
3
- "version": "0.0.49",
3
+ "version": "0.0.51",
4
4
  "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
5
5
  "main": "cli.js",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "cli.js",
11
11
  "cli/",
12
12
  "plugins/",
13
+ "skills/",
13
14
  "web-ui.html",
14
15
  "lib/",
15
16
  "web-ui/",
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: codexmate-project-context-recovery
3
+ description: Recovers project handoff context from local Codex, Claude Code, Gemini, CodeBuddy, and codexmate-derived sessions. Use when the user asks what happened in prior project/PR/branch/file/error work, needs a handoff brief, wants old decisions or validations recovered, or asks to summarize cross-session project activity with evidence.
4
+ ---
5
+
6
+ # Codexmate Project Context Recovery
7
+
8
+ ## Overview
9
+
10
+ Use this skill to build an evidence-first project handoff brief from local agent sessions. It is not semantic memory or a live GitHub source of truth. It works best with hard identifiers: `owner/repo`, branch names, file paths, commit hashes, exact errors, PR/issue numbers plus repo filters, or unique commands.
11
+
12
+ Old sessions are historical evidence. Mutable facts such as PR state, CI, releases, deployments, and current files still require live checks before action.
13
+
14
+ ## Quick Start
15
+
16
+ Generate a context brief first. Resolve `scripts/search_sessions.py` relative to this skill directory; after npm installation the full package path is `node_modules/codexmate/skills/codexmate-project-context-recovery/scripts/search_sessions.py`.
17
+
18
+ ```bash
19
+ python3 scripts/search_sessions.py "SakuraByteCore/codexmate feat/task-orchestration-tab" --mode brief --source all --path-filter codexmate --match all --format text --limit 8
20
+ ```
21
+
22
+ Use plain search when you need raw candidates:
23
+
24
+ ```bash
25
+ python3 scripts/search_sessions.py "exact error text" --mode search --source all --path-filter codexmate --format text --limit 10
26
+ ```
27
+
28
+ ## Workflow
29
+
30
+ 1. **Confirm the object**: repo/project, PR/issue number, branch, file path, command, exact error text, person, or date range. If multiple projects match, state the chosen object before searching.
31
+ 2. **Prefer hard identifiers**: exact `owner/repo`, short repo name, PR/issue number with repo filter, branch, file path, commit hash, exact error, then user wording.
32
+ 3. **Generate a brief**: use `--mode brief`; add `--path-filter` for project/worktree isolation; use `--match all` when the query has strong identifiers.
33
+ 4. **Check confidence**: `high` means multiple hard signals appeared; `medium/weak` means re-query with stronger identifiers before relying on it; `none` means no hits were found.
34
+ 5. **Use the brief as a handoff**: extract timeline, decisions, validations, risks, files, commands, commits, and top evidence sessions.
35
+ 6. **Live-check mutable facts**: before commenting, merging, releasing, or claiming current status, check GitHub/current files directly.
36
+
37
+ ## What Good Output Looks Like
38
+
39
+ - **Target Object:** repo / PR / branch / file / error
40
+ - **Context Brief:** confidence, top sources, timeline, repos/branches/PRs/files
41
+ - **Historical Evidence:** session source/id/path + snippets
42
+ - **Handoff Summary:** decisions, validations, risks, commands, commits
43
+ - **Live Verification:** PR/check/review/release/current-file facts that still need live verification
44
+
45
+ ## Optional codexmate MCP Path
46
+
47
+ If codexmate MCP is configured and healthy, use it to inspect strong candidates:
48
+
49
+ - `codexmate.session.list` with `source`, `query`, `queryScope: "all"`, `limit`, and `forceRefresh: true`.
50
+ - `codexmate.session.detail` for candidate session inspection.
51
+ - `codexmate.session.export` only when a markdown export is useful.
52
+
53
+ If MCP is unavailable, do not block; run `scripts/search_sessions.py`.
54
+
55
+ ## Limits
56
+
57
+ - Generic natural-language queries can be noisy.
58
+ - Short PR numbers without repo/path filters are weak signals.
59
+ - Session logs may contain stale, failed, or speculative work.
60
+ - The brief should guide investigation; it must not replace current repo/GitHub verification.
61
+
62
+ ## Privacy
63
+
64
+ Share only context relevant to the current task. Do not quote credentials, unrelated personal details, private memory, or large transcript chunks. In group chats, summarize narrowly and avoid exposing unrelated sessions.
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env python3
2
+ """Search local agent sessions and build project context briefs.
3
+
4
+ This script is intentionally dependency-free so Claude Code/Codex can run it from a skill.
5
+ It supports two modes:
6
+ - search: locate matching sessions and snippets
7
+ - brief: synthesize a structured, evidence-first context brief from matching sessions
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Iterable
20
+
21
+ SESSION_EXTS = {".jsonl", ".json", ".md", ".txt"}
22
+ DEFAULT_LIMIT = 20
23
+ DEFAULT_MAX_BYTES = 1024 * 1024
24
+ SYSTEM_NOISE = (
25
+ "<permissions instructions>",
26
+ "# agents.md instructions",
27
+ "<instructions>",
28
+ "available_skills",
29
+ "<environment_context>",
30
+ "$codex_home/skills",
31
+ "skill-installer:",
32
+ "skill-creator:",
33
+ "trigger rules:",
34
+ "missing/blocked:",
35
+ "base_instructions",
36
+ "truncation_policy",
37
+ "you are codex",
38
+ "you are an ai",
39
+ "filesystem sandboxing",
40
+ "output verbosity",
41
+ "token_count",
42
+ "rate_limits",
43
+ )
44
+ VALIDATION_TERMS = (
45
+ "npm run", "pnpm", "yarn", "pytest", "vitest", "test", "lint", "build",
46
+ "passed", "failed", "packaging skill", "skill is valid", "all ", "checks",
47
+ )
48
+ RISK_TERMS = (
49
+ "failed", "failure", "error", "blocker", "pending", "review_required", "not logged in",
50
+ "no matching", "cannot", "can't", "skipped", "risk", "todo", "unverified",
51
+ )
52
+ DECISION_TERMS = (
53
+ "add", "added", "fix", "fixed", "update", "updated", "remove", "removed",
54
+ "rename", "renamed", "implement", "implemented", "decided", "must", "should",
55
+ )
56
+
57
+
58
+ @dataclass
59
+ class Hit:
60
+ source: str
61
+ file: str
62
+ session_id: str
63
+ updated_at: float
64
+ score: int
65
+ snippets: list[str]
66
+ cwd: str = ""
67
+ title: str = ""
68
+ metadata: dict[str, list[str]] | None = None
69
+
70
+
71
+ def home() -> Path:
72
+ return Path(os.path.expanduser("~"))
73
+
74
+
75
+ def positive_int(value: str) -> int:
76
+ try:
77
+ parsed = int(value)
78
+ except ValueError as exc:
79
+ raise argparse.ArgumentTypeError(f"{value!r} is not an integer") from exc
80
+ if parsed <= 0:
81
+ raise argparse.ArgumentTypeError(f"{value!r} must be greater than 0")
82
+ return parsed
83
+
84
+
85
+ def candidate_roots(selected: str) -> list[tuple[str, Path]]:
86
+ h = home()
87
+ roots = [
88
+ ("codex", h / ".codex" / "sessions"),
89
+ ("claude", h / ".claude" / "projects"),
90
+ ("codexmate-derived-codex", h / ".codexmate" / "sessions" / "derived" / "codex"),
91
+ ("codexmate-derived-claude", h / ".codexmate" / "sessions" / "derived" / "claude"),
92
+ ("gemini", h / ".gemini"),
93
+ ("codebuddy", h / ".codebuddy"),
94
+ ]
95
+ if selected == "all":
96
+ return roots
97
+ return [(name, path) for name, path in roots if name == selected or name.startswith(f"{selected}-")]
98
+
99
+
100
+ def iter_files(root: Path, max_files: int) -> Iterable[Path]:
101
+ if not root.exists():
102
+ return
103
+ count = 0
104
+ for path in root.rglob("*"):
105
+ if count >= max_files:
106
+ return
107
+ if not path.is_file() or path.suffix.lower() not in SESSION_EXTS:
108
+ continue
109
+ name = path.name.lower()
110
+ if name in {"sessions-index.json", "settings.json"}:
111
+ continue
112
+ count += 1
113
+ yield path
114
+
115
+
116
+ def read_tail(path: Path, max_bytes: int) -> str:
117
+ try:
118
+ size = path.stat().st_size
119
+ with path.open("rb") as f:
120
+ if size > max_bytes:
121
+ f.seek(max(0, size - max_bytes))
122
+ data = f.read(max_bytes)
123
+ return data.decode("utf-8", errors="replace")
124
+ except OSError:
125
+ return ""
126
+
127
+
128
+ def token_groups(query: str) -> list[list[str]]:
129
+ groups: list[list[str]] = []
130
+ for raw in [t.lower() for t in re.split(r"\s+", query.strip()) if t.strip()]:
131
+ variants = [raw]
132
+ if "-" in raw:
133
+ variants.append(raw.replace("-", ""))
134
+ variants.extend(part for part in raw.split("-") if part)
135
+ if "/" in raw:
136
+ variants.extend(part for part in raw.split("/") if part)
137
+ seen = set()
138
+ groups.append([v for v in variants if v and not (v in seen or seen.add(v))])
139
+ return groups
140
+
141
+
142
+ def flatten_groups(groups: list[list[str]]) -> list[str]:
143
+ seen = set()
144
+ tokens: list[str] = []
145
+ for group in groups:
146
+ for token in group:
147
+ if token not in seen:
148
+ seen.add(token)
149
+ tokens.append(token)
150
+ return tokens
151
+
152
+
153
+ def contains_token(lower: str, path_text: str, token: str) -> bool:
154
+ return token in lower or token in path_text
155
+
156
+
157
+ def group_matches_all(lower: str, path_text: str, group: list[str]) -> bool:
158
+ raw = group[0]
159
+ if contains_token(lower, path_text, raw):
160
+ return True
161
+ if "-" in raw or "/" in raw:
162
+ parts = [part for part in re.split(r"[-/]", raw) if part]
163
+ if parts and all(contains_token(lower, path_text, part) for part in parts):
164
+ return True
165
+ return False
166
+
167
+
168
+ def noisy_string(value: str) -> bool:
169
+ lower = value.lower()
170
+ return any(noise in lower for noise in SYSTEM_NOISE)
171
+
172
+
173
+ def collect_strings(value: Any, out: list[str], key: str = "") -> None:
174
+ if value is None:
175
+ return
176
+ if isinstance(value, str):
177
+ if value.strip() and not noisy_string(value):
178
+ out.append(value)
179
+ return
180
+ if isinstance(value, list):
181
+ for item in value:
182
+ collect_strings(item, out, key)
183
+ return
184
+ if isinstance(value, dict):
185
+ for child_key, child_value in value.items():
186
+ if child_key in {"base_instructions", "truncation_policy", "permissions"}:
187
+ continue
188
+ collect_strings(child_value, out, child_key)
189
+
190
+
191
+ def json_role(obj: dict[str, Any]) -> str:
192
+ role = obj.get("role")
193
+ if isinstance(role, str):
194
+ return role.lower()
195
+ message = obj.get("message")
196
+ if isinstance(message, dict) and isinstance(message.get("role"), str):
197
+ return message["role"].lower()
198
+ payload = obj.get("payload")
199
+ if isinstance(payload, dict):
200
+ payload_role = payload.get("role")
201
+ if isinstance(payload_role, str):
202
+ return payload_role.lower()
203
+ payload_msg = payload.get("message")
204
+ if isinstance(payload_msg, dict) and isinstance(payload_msg.get("role"), str):
205
+ return payload_msg["role"].lower()
206
+ return ""
207
+
208
+
209
+ def useful_json_record(obj: dict[str, Any]) -> bool:
210
+ role = json_role(obj)
211
+ if role in {"system", "developer"}:
212
+ return False
213
+ typ = str(obj.get("type", "")).lower()
214
+ subtype = str(obj.get("subtype", "")).lower()
215
+ if typ in {"system"} or subtype in {"init"}:
216
+ return False
217
+ return True
218
+
219
+
220
+ def extract_search_text(raw: str) -> str:
221
+ records: list[str] = []
222
+ for line in raw.splitlines():
223
+ stripped = line.strip()
224
+ if not stripped:
225
+ continue
226
+ parsed: Any | None = None
227
+ if stripped.startswith("{"):
228
+ try:
229
+ parsed = json.loads(stripped)
230
+ except json.JSONDecodeError:
231
+ parsed = None
232
+ if isinstance(parsed, dict):
233
+ strings: list[str] = []
234
+ # Preserve useful metadata even when a record is otherwise noisy.
235
+ for key in ("cwd", "working_dir", "workingDirectory", "title", "summary"):
236
+ if isinstance(parsed.get(key), str):
237
+ strings.append(parsed[key])
238
+ git = parsed.get("git")
239
+ if isinstance(git, dict):
240
+ for key in ("branch", "repository_url", "commit_hash"):
241
+ if isinstance(git.get(key), str):
242
+ strings.append(git[key])
243
+ if useful_json_record(parsed):
244
+ collect_strings(parsed, strings)
245
+ clean_strings = [value for value in strings if not noisy_string(value)]
246
+ if clean_strings:
247
+ records.append(" ".join(clean_strings))
248
+ else:
249
+ records.append(stripped)
250
+ text = "\n".join(records) if records else raw
251
+ cleaned_lines = []
252
+ for line in text.splitlines():
253
+ lower = line.lower()
254
+ if any(noise in lower for noise in SYSTEM_NOISE):
255
+ continue
256
+ cleaned_lines.append(line)
257
+ return "\n".join(cleaned_lines) or raw
258
+
259
+
260
+ def extract_json_field(text: str, names: list[str]) -> str:
261
+ for name in names:
262
+ m = re.search(rf'"{re.escape(name)}"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"', text)
263
+ if m:
264
+ try:
265
+ return json.loads('"' + m.group(1) + '"')
266
+ except Exception:
267
+ return m.group(1)
268
+ return ""
269
+
270
+
271
+ def make_snippets(text: str, tokens: list[str], max_snippets: int, phrase: str = "") -> list[str]:
272
+ lower = text.lower()
273
+ snippets: list[str] = []
274
+ ordered_tokens = ([phrase] if phrase else []) + tokens
275
+ for token in ordered_tokens:
276
+ if not token:
277
+ continue
278
+ start = lower.find(token)
279
+ if start < 0:
280
+ continue
281
+ lo = max(0, start - 120)
282
+ hi = min(len(text), start + len(token) + 240)
283
+ snippet = re.sub(r"\s+", " ", text[lo:hi]).strip()
284
+ if snippet and snippet not in snippets:
285
+ snippets.append(snippet)
286
+ if len(snippets) >= max_snippets:
287
+ break
288
+ return snippets
289
+
290
+
291
+ def score_text(lower: str, tokens: list[str], path_text: str, phrase: str = "") -> int:
292
+ score = 0
293
+ if phrase and phrase in lower:
294
+ score += 1000
295
+ if phrase and phrase in path_text:
296
+ score += 100
297
+ for token in tokens:
298
+ if token in lower:
299
+ score += lower.count(token)
300
+ if token in path_text:
301
+ score += 2
302
+ return score
303
+
304
+
305
+ def unique(items: Iterable[str], limit: int = 20) -> list[str]:
306
+ seen = set()
307
+ out: list[str] = []
308
+ for item in items:
309
+ value = item.strip().strip('"\'`,.;:()[]{}')
310
+ if not value or value in seen:
311
+ continue
312
+ seen.add(value)
313
+ out.append(value)
314
+ if len(out) >= limit:
315
+ break
316
+ return out
317
+
318
+
319
+ def extract_metadata(text: str, path_text: str = "") -> dict[str, list[str]]:
320
+ combined = f"{text}\n{path_text}"
321
+ github_repo = re.findall(r"github\.com[:/]+([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git|/|\s|\"|'|$)", combined)
322
+ # Avoid treating arbitrary paths like `cli/file.js` or `site/assets` as repositories.
323
+ # Repository identity is high-value only when it comes from a GitHub URL.
324
+ owner_repo: list[str] = []
325
+ branch_refs = re.findall(r"[\"'](?:branch|headRefName|gitBranch|baseRefName)[\"']\s*:\s*[\"']([A-Za-z0-9._/-]+)[\"']", combined, flags=re.IGNORECASE)
326
+ branch_like = re.findall(r"\b(?:feat|fix|chore|docs|refactor|test|release|hotfix)/[A-Za-z0-9._/-]+", combined)
327
+ pr_refs = re.findall(r"github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/pull/(\d+)", combined)
328
+ issue_refs = re.findall(r"github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/issues/(\d+)", combined)
329
+ pr_words = re.findall(r"\b(?:PR|pull request|pull|issue)\s*#?(\d{1,6})\b", combined, flags=re.IGNORECASE)
330
+ commits = re.findall(r"\b[0-9a-f]{7,40}\b", combined, flags=re.IGNORECASE)
331
+ files = re.findall(r"(?:^|[\s\"'`])([A-Za-z0-9_./-]+\.(?:js|mjs|cjs|ts|tsx|jsx|py|md|json|ya?ml|sh|css|html|toml|lock))\b", combined)
332
+ commands = re.findall(r"(?:^|\s)((?:npm|pnpm|yarn|python3?|node|gh|git|npx)\s+[^\n\r`]{1,160})", combined)
333
+ return {
334
+ "repos": unique(github_repo + owner_repo, 12),
335
+ "branches": unique(branch_refs + branch_like, 12),
336
+ "prs": unique(pr_refs + pr_words, 12),
337
+ "issues": unique(issue_refs, 12),
338
+ "commits": unique(commits, 12),
339
+ "files": unique(files, 20),
340
+ "commands": unique(commands, 12),
341
+ }
342
+
343
+
344
+ def select_sentences(text: str, terms: tuple[str, ...], limit: int) -> list[str]:
345
+ candidates = re.split(r"(?<=[.!?。!?])\s+|\n+", text)
346
+ selected: list[str] = []
347
+ for sentence in candidates:
348
+ clean = re.sub(r"\s+", " ", sentence).strip()
349
+ if len(clean) < 12 or len(clean) > 500:
350
+ continue
351
+ lower = clean.lower()
352
+ if any(term in lower for term in terms):
353
+ selected.append(clean)
354
+ if len(selected) >= limit:
355
+ break
356
+ return unique(selected, limit)
357
+
358
+
359
+ def search(args: argparse.Namespace) -> list[Hit]:
360
+ groups = token_groups(args.query)
361
+ tokens = flatten_groups(groups)
362
+ phrase = re.sub(r"\s+", " ", args.query.strip().lower())
363
+ if not tokens:
364
+ raise SystemExit("query is required")
365
+ hits: list[Hit] = []
366
+ for source, root in candidate_roots(args.source):
367
+ for path in iter_files(root, args.max_files_per_root):
368
+ raw_text = read_tail(path, args.max_bytes)
369
+ if not raw_text:
370
+ continue
371
+ text = extract_search_text(raw_text)
372
+ lower = text.lower()
373
+ path_text = str(path).lower()
374
+ path_filter = (args.path_filter or "").strip().lower()
375
+ if path_filter and path_filter not in path_text and path_filter not in lower:
376
+ continue
377
+ if args.match == "all" and not all(group_matches_all(lower, path_text, group) for group in groups):
378
+ continue
379
+ if args.match == "any" and not any(contains_token(lower, path_text, t) for t in tokens):
380
+ continue
381
+ score = score_text(lower, tokens, path_text, phrase)
382
+ if score <= 0:
383
+ continue
384
+ try:
385
+ stat = path.stat()
386
+ updated_at = stat.st_mtime
387
+ except OSError:
388
+ updated_at = 0
389
+ hits.append(Hit(
390
+ source=source,
391
+ file=str(path),
392
+ session_id=path.stem,
393
+ updated_at=updated_at,
394
+ score=score,
395
+ snippets=make_snippets(text, tokens, args.snippets, phrase),
396
+ cwd=extract_json_field(raw_text[:65536], ["cwd", "working_dir", "workingDirectory"]),
397
+ title=extract_json_field(raw_text[:65536], ["title", "summary", "firstPrompt"]),
398
+ metadata=extract_metadata(text, str(path)),
399
+ ))
400
+ hits.sort(key=lambda h: (h.score, h.updated_at), reverse=True)
401
+ return hits[: args.limit]
402
+
403
+
404
+ def build_brief(args: argparse.Namespace, hits: list[Hit]) -> dict[str, Any]:
405
+ aggregate: dict[str, list[str]] = {
406
+ "repos": [], "branches": [], "prs": [], "issues": [], "commits": [], "files": [], "commands": [],
407
+ "validations": [], "decisions": [], "risks": [],
408
+ }
409
+ timeline = []
410
+ for hit in hits:
411
+ meta = hit.metadata or {}
412
+ for key in ("repos", "branches", "prs", "issues", "commits", "files", "commands"):
413
+ aggregate[key].extend(meta.get(key, []))
414
+ session_text = extract_search_text(read_tail(Path(hit.file), args.max_bytes))
415
+ aggregate["validations"].extend(select_sentences(session_text, VALIDATION_TERMS, 4))
416
+ aggregate["decisions"].extend(select_sentences(session_text, DECISION_TERMS, 4))
417
+ aggregate["risks"].extend(select_sentences(session_text, RISK_TERMS, 4))
418
+ timeline.append({
419
+ "updatedAt": datetime.fromtimestamp(hit.updated_at, timezone.utc).isoformat() if hit.updated_at else "",
420
+ "source": hit.source,
421
+ "sessionId": hit.session_id,
422
+ "cwd": hit.cwd,
423
+ "title": hit.title,
424
+ "score": hit.score,
425
+ "evidence": hit.snippets[:2],
426
+ "file": hit.file,
427
+ })
428
+ for key in aggregate:
429
+ aggregate[key] = unique(aggregate[key], 20)
430
+ hard_signal_count = sum(1 for key in ("repos", "branches", "commits", "files") if aggregate[key])
431
+ confidence = "none"
432
+ if hits:
433
+ confidence = "high" if hard_signal_count >= 2 and hits[0].score >= 20 else "medium" if hits[0].score >= 5 else "weak"
434
+ return {
435
+ "query": args.query,
436
+ "source": args.source,
437
+ "pathFilter": args.path_filter,
438
+ "match": args.match,
439
+ "generatedAt": datetime.now(timezone.utc).isoformat(),
440
+ "confidence": confidence,
441
+ "summary": {
442
+ "hitCount": len(hits),
443
+ "topSources": unique([hit.source for hit in hits], 8),
444
+ "repos": aggregate["repos"][:8],
445
+ "branches": aggregate["branches"][:8],
446
+ "prs": aggregate["prs"][:8],
447
+ "files": aggregate["files"][:10],
448
+ },
449
+ "timeline": timeline[: args.brief_items],
450
+ "evidence": {
451
+ "commits": aggregate["commits"][:10],
452
+ "commands": aggregate["commands"][:10],
453
+ "validations": aggregate["validations"][:8],
454
+ "decisions": aggregate["decisions"][:8],
455
+ "risks": aggregate["risks"][:8],
456
+ },
457
+ "handoff": [
458
+ "Use the listed sessions as historical evidence, not current truth.",
459
+ "Live-check GitHub PR/issue/check/release state before acting on mutable facts.",
460
+ "Re-query with stronger identifiers if confidence is weak or hits are generic.",
461
+ ],
462
+ }
463
+
464
+
465
+ def emit_json(hits: list[Hit]) -> None:
466
+ print(json.dumps({"hits": [hit.__dict__ for hit in hits]}, ensure_ascii=False, indent=2))
467
+
468
+
469
+ def emit_text(hits: list[Hit]) -> None:
470
+ if not hits:
471
+ print("No matching sessions found.")
472
+ return
473
+ for index, hit in enumerate(hits, 1):
474
+ print(f"{index}. [{hit.source}] score={hit.score} session={hit.session_id}")
475
+ print(f" file: {hit.file}")
476
+ if hit.cwd:
477
+ print(f" cwd: {hit.cwd}")
478
+ if hit.title:
479
+ print(f" title: {hit.title}")
480
+ for snippet in hit.snippets:
481
+ print(f" - {snippet}")
482
+
483
+
484
+ def emit_brief_text(brief: dict[str, Any]) -> None:
485
+ print(f"# Project Context Brief: {brief['query']}")
486
+ print(f"confidence: {brief['confidence']} | hits: {brief['summary']['hitCount']} | source: {brief['source']}")
487
+ if brief.get("pathFilter"):
488
+ print(f"path-filter: {brief['pathFilter']}")
489
+ summary = brief["summary"]
490
+ for label, key in (("repos", "repos"), ("branches", "branches"), ("prs", "prs"), ("files", "files")):
491
+ values = summary.get(key) or []
492
+ if values:
493
+ print(f"\n## {label}")
494
+ for value in values:
495
+ print(f"- {value}")
496
+ print("\n## timeline")
497
+ if not brief["timeline"]:
498
+ print("- No matching sessions found.")
499
+ for item in brief["timeline"]:
500
+ print(f"- {item['updatedAt']} [{item['source']}] {item['sessionId']} score={item['score']}")
501
+ if item.get("cwd"):
502
+ print(f" cwd: {item['cwd']}")
503
+ if item.get("file"):
504
+ print(f" file: {item['file']}")
505
+ for evidence in item.get("evidence", []):
506
+ print(f" evidence: {evidence}")
507
+ evidence = brief["evidence"]
508
+ for label, key in (("validations", "validations"), ("decisions", "decisions"), ("risks", "risks"), ("commands", "commands"), ("commits", "commits")):
509
+ values = evidence.get(key) or []
510
+ if values:
511
+ print(f"\n## {label}")
512
+ for value in values:
513
+ print(f"- {value}")
514
+ print("\n## handoff")
515
+ for item in brief["handoff"]:
516
+ print(f"- {item}")
517
+
518
+
519
+ def main() -> int:
520
+ parser = argparse.ArgumentParser(description="Search local agent sessions and build project context briefs.")
521
+ parser.add_argument("query", help="Search query, e.g. owner/repo, PR number, branch, file path, or exact error text")
522
+ parser.add_argument("--mode", default="search", choices=["search", "brief"], help="search returns hits; brief returns a structured handoff summary")
523
+ parser.add_argument("--source", default="all", choices=["all", "codex", "claude", "gemini", "codebuddy", "codexmate-derived", "codexmate-derived-codex", "codexmate-derived-claude"], help="Session source to search")
524
+ parser.add_argument("--match", default="any", choices=["any", "all"], help="Whether any or all query tokens must match")
525
+ parser.add_argument("--path-filter", default="", help="Optional substring that must appear in the session path or content, useful for project/worktree filtering")
526
+ parser.add_argument("--limit", type=positive_int, default=DEFAULT_LIMIT, help="Maximum hits to scan/print")
527
+ parser.add_argument("--brief-items", type=positive_int, default=8, help="Maximum timeline items in brief mode")
528
+ parser.add_argument("--snippets", type=positive_int, default=2, help="Maximum snippets per hit")
529
+ parser.add_argument("--max-bytes", type=positive_int, default=DEFAULT_MAX_BYTES, help="Tail bytes to scan per session file")
530
+ parser.add_argument("--max-files-per-root", type=positive_int, default=5000, help="Maximum files to scan per root")
531
+ parser.add_argument("--format", choices=["json", "text"], default="json")
532
+ args = parser.parse_args()
533
+ hits = search(args)
534
+ if args.mode == "brief":
535
+ brief = build_brief(args, hits)
536
+ if args.format == "text":
537
+ emit_brief_text(brief)
538
+ else:
539
+ print(json.dumps(brief, ensure_ascii=False, indent=2))
540
+ return 0
541
+ if args.format == "text":
542
+ emit_text(hits)
543
+ else:
544
+ emit_json(hits)
545
+ return 0
546
+
547
+
548
+ if __name__ == "__main__":
549
+ sys.exit(main())
package/web-ui/app.js CHANGED
@@ -752,6 +752,9 @@ document.addEventListener('DOMContentLoaded', () => {
752
752
  watch: {
753
753
  mainTab(newTab) {
754
754
  if (newTab === 'prompts' && typeof this.loadPromptsContent === 'function') {
755
+ if (this.promptsSubTab === 'claude-project' && !this.projectPathOptions.length && !this.projectPathOptionsLoading && typeof this.loadProjectPathOptions === 'function') {
756
+ this.loadProjectPathOptions();
757
+ }
755
758
  this.loadPromptsContent();
756
759
  }
757
760
  },
@@ -727,6 +727,9 @@ export function createAgentsMethods(options = {}) {
727
727
  const rpcParams = {};
728
728
  if (subTab === 'claude-project') {
729
729
  action = 'get-claude-md-file';
730
+ if (!this.projectPathOptions.length && !this.projectPathOptionsLoading && typeof this.loadProjectPathOptions === 'function') {
731
+ this.loadProjectPathOptions();
732
+ }
730
733
  const projectPath = (this.projectClaudeMdPath || '').trim();
731
734
  if (projectPath) {
732
735
  rpcParams.baseDir = projectPath;
@@ -546,6 +546,7 @@
546
546
 
547
547
  .prompts-editor-toolbar .form-hint {
548
548
  margin-top: 0;
549
+ font-size: 9px;
549
550
  }
550
551
 
551
552
  .prompts-editor-actions {