codexmate 0.0.48 → 0.0.50
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 +2 -0
- package/README.zh.md +2 -0
- package/cli/agents-files.js +97 -28
- package/cli.js +10 -2
- package/package.json +2 -1
- package/skills/codexmate-project-context-recovery/SKILL.md +64 -0
- package/skills/codexmate-project-context-recovery/scripts/search_sessions.py +549 -0
- package/web-ui/app.js +28 -1
- package/web-ui/logic.agents-diff.mjs +6 -0
- package/web-ui/modules/app.methods.agents.mjs +82 -16
- package/web-ui/modules/i18n/locales/en.mjs +12 -6
- package/web-ui/modules/i18n/locales/ja.mjs +12 -6
- package/web-ui/modules/i18n/locales/vi.mjs +12 -2
- package/web-ui/modules/i18n/locales/zh-tw.mjs +12 -6
- package/web-ui/modules/i18n/locales/zh.mjs +12 -6
- package/web-ui/partials/index/layout-header.html +5 -5
- package/web-ui/partials/index/modal-config-template-agents.html +2 -2
- package/web-ui/partials/index/panel-prompts.html +24 -2
- package/web-ui/res/web-ui-render.precompiled.js +47 -11
- package/web-ui/styles/modals-core.css +34 -0
- package/web-ui/styles/titles-cards.css +2 -2
|
@@ -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
|
@@ -74,6 +74,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
74
74
|
showConfigTemplateModal: false,
|
|
75
75
|
showAgentsModal: false,
|
|
76
76
|
promptsSubTab: 'codex',
|
|
77
|
+
projectClaudeMdPath: '',
|
|
78
|
+
projectPathOptions: [],
|
|
79
|
+
projectPathOptionsLoading: false,
|
|
77
80
|
showSkillsModal: false,
|
|
78
81
|
showHealthCheckModal: false,
|
|
79
82
|
showCodexBridgePoolModal: false,
|
|
@@ -574,6 +577,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
574
577
|
this.sessionTrashEnabled = this.normalizeSessionTrashEnabled(localStorage.getItem('codexmateSessionTrashEnabled'));
|
|
575
578
|
this.sessionTrashRetentionDays = this.normalizeSessionTrashRetentionDays(localStorage.getItem('codexmateSessionTrashRetentionDays'));
|
|
576
579
|
this.configTemplateDiffConfirmEnabled = loadConfigTemplateDiffConfirmEnabledFromStorage(localStorage);
|
|
580
|
+
try {
|
|
581
|
+
var savedProjectPath = localStorage.getItem('codexmate_project_claude_md_path');
|
|
582
|
+
if (savedProjectPath) {
|
|
583
|
+
this.projectClaudeMdPath = savedProjectPath;
|
|
584
|
+
}
|
|
585
|
+
} catch (_) {}
|
|
586
|
+
try {
|
|
587
|
+
var savedSubTab = localStorage.getItem('codexmate_prompts_sub_tab');
|
|
588
|
+
if (savedSubTab === 'codex' || savedSubTab === 'claude-project') {
|
|
589
|
+
this.promptsSubTab = savedSubTab;
|
|
590
|
+
}
|
|
591
|
+
} catch (_) {}
|
|
577
592
|
window.addEventListener('resize', this.onWindowResize);
|
|
578
593
|
window.addEventListener('keydown', this.handleGlobalKeydown);
|
|
579
594
|
window.addEventListener('beforeunload', this.handleBeforeUnload);
|
|
@@ -740,10 +755,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
740
755
|
this.loadPromptsContent();
|
|
741
756
|
}
|
|
742
757
|
},
|
|
743
|
-
promptsSubTab() {
|
|
758
|
+
promptsSubTab(newVal) {
|
|
759
|
+
try {
|
|
760
|
+
localStorage.setItem('codexmate_prompts_sub_tab', newVal);
|
|
761
|
+
} catch (_) {}
|
|
744
762
|
if (this.mainTab === 'prompts' && typeof this.loadPromptsContent === 'function') {
|
|
745
763
|
this.loadPromptsContent();
|
|
746
764
|
}
|
|
765
|
+
},
|
|
766
|
+
projectClaudeMdPath(newPath) {
|
|
767
|
+
try {
|
|
768
|
+
if (newPath) {
|
|
769
|
+
localStorage.setItem('codexmate_project_claude_md_path', newPath);
|
|
770
|
+
} else {
|
|
771
|
+
localStorage.removeItem('codexmate_project_claude_md_path');
|
|
772
|
+
}
|
|
773
|
+
} catch (_) {}
|
|
747
774
|
}
|
|
748
775
|
},
|
|
749
776
|
|
|
@@ -336,6 +336,12 @@ export function buildAgentsDiffPreviewRequest(options = {}) {
|
|
|
336
336
|
params.fileName = fileName;
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
|
+
if (context === 'claude-project') {
|
|
340
|
+
const baseDir = typeof options.baseDir === 'string' ? options.baseDir.trim() : '';
|
|
341
|
+
if (baseDir) {
|
|
342
|
+
params.baseDir = baseDir;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
339
345
|
|
|
340
346
|
const maxRequestBytes = Number.isFinite(options.maxRequestBytes)
|
|
341
347
|
? Math.max(1024, Math.floor(options.maxRequestBytes))
|