@suwujs/codex-vault 0.5.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suwujs/codex-vault",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Persistent knowledge vault for LLM agents (Claude Code, Codex CLI)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/plugin/VERSION CHANGED
@@ -1 +1 @@
1
- 0.5.0
1
+ 0.5.2
@@ -273,27 +273,30 @@ def main():
273
273
 
274
274
  if parts:
275
275
  context = "\n\n".join(parts)
276
+
277
+ # Build feedback label
278
+ matched = [s for s in SIGNALS if _match(s["patterns"], prompt.lower())]
279
+ feedback_parts = []
280
+ for s in matched:
281
+ feedback_parts.append(f"{s['name']} → {s['skill']}")
282
+ if is_session_end(prompt):
283
+ feedback_parts.append("SESSION END → /wrap-up")
284
+ icon = "🔄" if mode == "auto" else "💡"
285
+ label = ", ".join(feedback_parts) if feedback_parts else "intent detected"
286
+
287
+ # Hook trigger notification
288
+ print(f" {icon} vault: {label}")
289
+
276
290
  output = {
277
291
  "hookSpecificOutput": {
278
292
  "hookEventName": "UserPromptSubmit",
279
293
  "additionalContext": context
280
- }
294
+ },
295
+ "systemMessage": f"{icon} vault: {label}"
281
296
  }
282
297
  json.dump(output, sys.stdout)
283
298
  sys.stdout.flush()
284
299
 
285
- # Visible feedback to user terminal (stderr)
286
- matched = [s for s in SIGNALS if _match(s["patterns"], prompt.lower())]
287
- parts = []
288
- for s in matched:
289
- parts.append(f"{s['name']} → {s['skill']}")
290
- if is_session_end(prompt):
291
- parts.append("SESSION END → /wrap-up")
292
- if parts:
293
- label = ", ".join(parts)
294
- icon = "🔄" if mode == "auto" else "💡"
295
- print(f" {icon} vault: {label}", file=sys.stderr)
296
-
297
300
  sys.exit(0)
298
301
 
299
302
 
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env python3
2
+ """Session-start hook — injects vault context into the agent's prompt.
3
+
4
+ Works with any agent that supports SessionStart hooks (Claude Code, Codex CLI).
5
+ Outputs structured JSON: additionalContext for LLM, systemMessage for terminal.
6
+
7
+ Dynamic context: adapts git log window, reads full North Star,
8
+ shows all active work, and includes uncommitted changes.
9
+ """
10
+ import json
11
+ import os
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ from collections import Counter
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+
19
+
20
+ def _find_vault_root():
21
+ """Find vault root from CWD — check for Home.md/brain/, then vault/ subdir."""
22
+ cwd = os.environ.get("CLAUDE_PROJECT_DIR",
23
+ os.environ.get("CODEX_PROJECT_DIR", os.getcwd()))
24
+ if os.path.isfile(os.path.join(cwd, "Home.md")) or os.path.isdir(os.path.join(cwd, "brain")):
25
+ return cwd
26
+ vault_sub = os.path.join(cwd, "vault")
27
+ if os.path.isdir(vault_sub) and (
28
+ os.path.isfile(os.path.join(vault_sub, "Home.md")) or
29
+ os.path.isdir(os.path.join(vault_sub, "brain"))
30
+ ):
31
+ return vault_sub
32
+ return None
33
+
34
+
35
+ def _run_git(args, cwd, timeout=5):
36
+ """Run git command and return stdout lines."""
37
+ try:
38
+ result = subprocess.run(
39
+ ["git"] + args,
40
+ capture_output=True, text=True, cwd=cwd, timeout=timeout,
41
+ )
42
+ return result.stdout.strip().splitlines() if result.stdout.strip() else []
43
+ except Exception:
44
+ return []
45
+
46
+
47
+ def _git_log_oneline(cwd, since=None, max_count=None):
48
+ """Get git log --oneline entries."""
49
+ args = ["log", "--oneline", "--no-merges"]
50
+ if since:
51
+ args.append(f"--since={since}")
52
+ if max_count:
53
+ args.extend(["-n", str(max_count)])
54
+ return _run_git(args, cwd)
55
+
56
+
57
+ def _git_status_short(cwd):
58
+ """Get git status --short output."""
59
+ return _run_git(["status", "--short", "--", "."], cwd)
60
+
61
+
62
+ def _read_file(path):
63
+ """Read file content, return empty string on error."""
64
+ try:
65
+ return Path(path).read_text(encoding="utf-8")
66
+ except Exception:
67
+ return ""
68
+
69
+
70
+ def _find_md_files(vault_dir):
71
+ """Find all .md files in vault, excluding non-vault directories."""
72
+ exclude = {".git", ".obsidian", "thinking", ".claude", ".codex",
73
+ ".codex-vault", ".codex-mem", "node_modules"}
74
+ files = []
75
+ for root, dirs, filenames in os.walk(vault_dir):
76
+ dirs[:] = [d for d in dirs if d not in exclude]
77
+ for f in filenames:
78
+ if f.endswith(".md"):
79
+ rel = os.path.relpath(os.path.join(root, f), vault_dir)
80
+ files.append(f"./{rel}")
81
+ return sorted(files)
82
+
83
+
84
+ def _folder_summary(all_files):
85
+ """Generate folder summary with file counts."""
86
+ folders = Counter()
87
+ for f in all_files:
88
+ parts = f[2:].split("/") # strip ./
89
+ folders[parts[0] if len(parts) > 1 else "."] += 1
90
+ return [f" {folder}/ ({count} files)"
91
+ for folder, count in folders.most_common()]
92
+
93
+
94
+ def _key_files(all_files):
95
+ """Filter for key vault files."""
96
+ pattern = re.compile(
97
+ r"(Home|Index|North Star|Memories|Key Decisions|Patterns|log)\.md$")
98
+ return [f for f in all_files if pattern.search(f)]
99
+
100
+
101
+ def _mtime_ok(vault_dir, rel_path, cutoff):
102
+ """Check if file was modified after cutoff timestamp."""
103
+ try:
104
+ return os.path.getmtime(os.path.join(vault_dir, rel_path.lstrip("./"))) > cutoff
105
+ except Exception:
106
+ return False
107
+
108
+
109
+ def _north_star_goal(vault_dir):
110
+ """Extract first goal from North Star for banner display."""
111
+ ns_path = os.path.join(vault_dir, "brain", "North Star.md")
112
+ if not os.path.isfile(ns_path):
113
+ return None
114
+ content = _read_file(ns_path)
115
+ in_focus = False
116
+ for line in content.splitlines():
117
+ if re.match(r"^## Current Focus", line):
118
+ in_focus = True
119
+ continue
120
+ if in_focus and line.startswith("## "):
121
+ break
122
+ if in_focus and re.match(r"^- .+", line):
123
+ goal = line[2:].strip()
124
+ return goal[:40] if goal else None
125
+ return None
126
+
127
+
128
+ # ── Context builder (→ additionalContext for LLM) ──────────────────────
129
+
130
+
131
+ def _build_context(vault_dir):
132
+ lines = []
133
+ lines.append("## Session Context")
134
+ lines.append("")
135
+
136
+ # Date
137
+ lines.append("### Date")
138
+ now = datetime.now()
139
+ lines.append(f"{now.strftime('%Y-%m-%d')} ({now.strftime('%A')})")
140
+ lines.append("")
141
+
142
+ # North Star — full file
143
+ lines.append("### North Star")
144
+ ns_path = os.path.join(vault_dir, "brain", "North Star.md")
145
+ if os.path.isfile(ns_path):
146
+ lines.append(_read_file(ns_path))
147
+ else:
148
+ lines.append("(No North Star found — create brain/North Star.md to set goals)")
149
+ lines.append("")
150
+
151
+ # Recent changes — adaptive window
152
+ lines.append("### Recent Changes")
153
+ commits_48h = _git_log_oneline(vault_dir, since="48 hours ago")
154
+ if commits_48h:
155
+ lines.append("(last 48 hours)")
156
+ lines.extend(commits_48h[:15])
157
+ else:
158
+ commits_7d = _git_log_oneline(vault_dir, since="7 days ago")
159
+ if commits_7d:
160
+ lines.append("(nothing in 48h — showing last 7 days)")
161
+ lines.extend(commits_7d[:15])
162
+ else:
163
+ lines.append("(nothing recent — showing last 5 commits)")
164
+ commits = _git_log_oneline(vault_dir, max_count=5)
165
+ lines.extend(commits if commits else ["(no git history)"])
166
+ lines.append("")
167
+
168
+ # Recent operations from log.md
169
+ lines.append("### Recent Operations")
170
+ log_path = os.path.join(vault_dir, "log.md")
171
+ if os.path.isfile(log_path):
172
+ entries = [l for l in _read_file(log_path).splitlines()
173
+ if l.startswith("## [")]
174
+ lines.extend(entries[-5:] if entries else ["(no entries in log.md)"])
175
+ else:
176
+ lines.append("(no log.md)")
177
+ lines.append("")
178
+
179
+ # Active work
180
+ lines.append("### Active Work")
181
+ active_dir = os.path.join(vault_dir, "work", "active")
182
+ if os.path.isdir(active_dir):
183
+ work_files = sorted(f for f in os.listdir(active_dir) if f.endswith(".md"))
184
+ if work_files:
185
+ for f in work_files:
186
+ lines.append(f.replace(".md", ""))
187
+ else:
188
+ lines.append("(none)")
189
+ else:
190
+ lines.append("(no work/active/ directory)")
191
+ lines.append("")
192
+
193
+ # Uncommitted changes
194
+ lines.append("### Uncommitted Changes")
195
+ changes = _git_status_short(vault_dir)
196
+ lines.extend(changes[:20] if changes else ["(working tree clean)"])
197
+ lines.append("")
198
+
199
+ # Recently modified brain files
200
+ lines.append("### Recently Modified Brain Files")
201
+ brain_dir = os.path.join(vault_dir, "brain")
202
+ if os.path.isdir(brain_dir):
203
+ cutoff = datetime.now().timestamp() - 7 * 86400
204
+ recent = sorted(
205
+ f.replace(".md", "")
206
+ for f in os.listdir(brain_dir)
207
+ if f.endswith(".md") and os.path.getmtime(os.path.join(brain_dir, f)) > cutoff
208
+ )
209
+ if recent:
210
+ lines.append("(modified in last 7 days)")
211
+ lines.extend(recent)
212
+ else:
213
+ lines.append("(no recent changes)")
214
+ lines.append("")
215
+
216
+ # Vault file listing — tiered
217
+ lines.append("### Vault Files")
218
+ all_files = _find_md_files(vault_dir)
219
+ n = len(all_files)
220
+
221
+ if n <= 20:
222
+ lines.extend(all_files)
223
+
224
+ elif n <= 50:
225
+ hot = [f for f in all_files
226
+ if not re.match(r"\./sources/|\./work/archive/", f)]
227
+ cold = n - len(hot)
228
+ lines.extend(hot)
229
+ if cold > 0:
230
+ lines.append("")
231
+ lines.append(f"(+ {cold} files in sources/ and work/archive/ — use /recall to search)")
232
+
233
+ elif n <= 150:
234
+ lines.append(f"({n} files — showing summary)")
235
+ lines.append("")
236
+ lines.extend(_folder_summary(all_files))
237
+ lines.append("")
238
+ lines.append("Recently modified (7 days):")
239
+ cutoff = datetime.now().timestamp() - 7 * 86400
240
+ recent = [f for f in all_files if _mtime_ok(vault_dir, f, cutoff)]
241
+ lines.extend(recent if recent else [" (none)"])
242
+ lines.append("")
243
+ lines.append("Key files:")
244
+ lines.extend(_key_files(all_files))
245
+
246
+ else:
247
+ lines.append(f"({n} files — showing summary)")
248
+ lines.append("")
249
+ lines.extend(_folder_summary(all_files))
250
+ lines.append("")
251
+ lines.append("Recently modified (3 days):")
252
+ cutoff = datetime.now().timestamp() - 3 * 86400
253
+ recent = [f for f in all_files if _mtime_ok(vault_dir, f, cutoff)]
254
+ lines.extend(recent if recent else [" (none)"])
255
+ lines.append("")
256
+ lines.append("Key files:")
257
+ lines.extend(_key_files(all_files))
258
+ lines.append("")
259
+ lines.append("Use /recall <topic> to search the vault.")
260
+
261
+ return "\n".join(lines)
262
+
263
+
264
+ # ── ANSI colors (oh-my-codex style) ──────────────────────────────────
265
+
266
+ RESET = "\x1b[0m"
267
+ BOLD = "\x1b[1m"
268
+ DIM = "\x1b[2m"
269
+ CYAN = "\x1b[36m"
270
+ GREEN = "\x1b[32m"
271
+ YELLOW = "\x1b[33m"
272
+ SEP = f" {DIM}|{RESET} "
273
+
274
+
275
+ def _c(code, text):
276
+ return f"{code}{text}{RESET}"
277
+
278
+
279
+ # ── Banner builder (→ systemMessage for terminal) ──────────────────────
280
+
281
+
282
+ def _build_banner(vault_dir):
283
+ # --- Line 1: statusline ---
284
+ elements = []
285
+
286
+ # Git branch
287
+ branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], vault_dir)
288
+ if branch:
289
+ elements.append(_c(CYAN, branch[0]))
290
+
291
+ # North Star goal
292
+ goal = _north_star_goal(vault_dir)
293
+ if goal:
294
+ elements.append(f"\U0001f3af {goal}")
295
+ else:
296
+ elements.append(_c(DIM, "\U0001f3af no goal set"))
297
+
298
+ # Active work
299
+ active_dir = os.path.join(vault_dir, "work", "active")
300
+ work_files = sorted(
301
+ f.replace(".md", "")
302
+ for f in os.listdir(active_dir) if f.endswith(".md")
303
+ ) if os.path.isdir(active_dir) else []
304
+ if work_files:
305
+ names = ", ".join(work_files[:3])
306
+ suffix = f" +{len(work_files) - 3}" if len(work_files) > 3 else ""
307
+ elements.append(_c(GREEN, f"active:{len(work_files)}") + _c(DIM, f" {names}{suffix}"))
308
+ else:
309
+ elements.append(_c(DIM, "active:0"))
310
+
311
+ # Changes
312
+ changes = _git_status_short(vault_dir)
313
+ if changes:
314
+ elements.append(_c(YELLOW, f"changes:{len(changes)}"))
315
+ else:
316
+ elements.append(_c(GREEN, "clean"))
317
+
318
+ label = _c(BOLD, "[Vault]")
319
+ statusline = label + " " + SEP.join(elements)
320
+
321
+ # --- Line 2+: recent commits ---
322
+ lines = ["", statusline]
323
+ commits = _git_log_oneline(vault_dir, since="7 days ago")
324
+ if not commits:
325
+ commits = _git_log_oneline(vault_dir, max_count=3)
326
+ if commits:
327
+ for c in commits[:3]:
328
+ # hash in cyan, message in dim
329
+ parts = c.split(" ", 1)
330
+ if len(parts) == 2:
331
+ lines.append(f" {_c(DIM, parts[0])} {_c(DIM, parts[1])}")
332
+ else:
333
+ lines.append(f" {_c(DIM, c)}")
334
+
335
+ # --- Line: vault file count ---
336
+ all_files = _find_md_files(vault_dir)
337
+ lines.append(_c(DIM, f" {len(all_files)} notes"))
338
+ lines.append("")
339
+
340
+ return "\n".join(lines)
341
+
342
+
343
+ # ── Main ───────────────────────────────────────────────────────────────
344
+
345
+
346
+ def main():
347
+ vault_dir = _find_vault_root()
348
+ if not vault_dir:
349
+ output = {
350
+ "hookSpecificOutput": {
351
+ "hookEventName": "SessionStart",
352
+ "additionalContext": "## Session Context\n\n(No vault found)"
353
+ }
354
+ }
355
+ json.dump(output, sys.stdout)
356
+ sys.exit(0)
357
+
358
+ # Read hook input for session metadata
359
+ try:
360
+ event = json.load(sys.stdin)
361
+ except Exception:
362
+ event = {}
363
+
364
+ context = _build_context(vault_dir)
365
+ banner = _build_banner(vault_dir)
366
+
367
+ # =================== Codex hook trigger notification ===================
368
+ print("✅ 【Hook 通知】SessionStart 已触发 — 会话启动/恢复完成")
369
+ print(f" 会话 ID: {event.get('sessionId', '未知')} | Matcher: {event.get('matcher', 'N/A')}")
370
+
371
+ output = {
372
+ "hookSpecificOutput": {
373
+ "hookEventName": "SessionStart",
374
+ "additionalContext": context
375
+ },
376
+ "systemMessage": "【Codex Hook】SessionStart 执行完毕 ✅\n" + banner
377
+ }
378
+
379
+ json.dump(output, sys.stdout)
380
+ sys.stdout.flush()
381
+ sys.exit(0)
382
+
383
+
384
+ if __name__ == "__main__":
385
+ try:
386
+ main()
387
+ except Exception:
388
+ # Never block session start
389
+ sys.exit(0)
@@ -94,23 +94,26 @@ def main():
94
94
 
95
95
  if warnings:
96
96
  hint_list = "\n".join(f" - {w}" for w in warnings)
97
+ count = len(warnings)
98
+ first = warnings[0]
99
+ if count == 1:
100
+ feedback = f"⚠️ vault: {basename} — {first}"
101
+ else:
102
+ feedback = f"⚠️ vault: {basename} — {first} (+{count - 1} more)"
103
+
104
+ # Hook trigger notification
105
+ print(f" {feedback}")
106
+
97
107
  output = {
98
108
  "hookSpecificOutput": {
99
109
  "hookEventName": "PostToolUse",
100
110
  "additionalContext": f"Vault warnings for `{basename}`:\n{hint_list}\nFix these before moving on."
101
- }
111
+ },
112
+ "systemMessage": feedback
102
113
  }
103
114
  json.dump(output, sys.stdout)
104
115
  sys.stdout.flush()
105
116
 
106
- # Visible feedback to user terminal (stderr)
107
- count = len(warnings)
108
- first = warnings[0]
109
- if count == 1:
110
- print(f" ⚠️ vault: {basename} — {first}", file=sys.stderr)
111
- else:
112
- print(f" ⚠️ vault: {basename} — {first} (+{count - 1} more)", file=sys.stderr)
113
-
114
117
  sys.exit(0)
115
118
 
116
119
 
package/plugin/install.sh CHANGED
@@ -113,7 +113,7 @@ hooks_rel = os.environ["CVAULT_HOOKS_REL"]
113
113
  new_hooks = {
114
114
  "SessionStart": [{
115
115
  "matcher": "startup|resume|compact",
116
- "hooks": [{"type": "command", "command": f"bash {hooks_rel}/session-start.sh", "timeout": 30}]
116
+ "hooks": [{"type": "command", "command": f"python3 {hooks_rel}/session-start.py", "timeout": 30}]
117
117
  }],
118
118
  "UserPromptSubmit": [{
119
119
  "hooks": [{"type": "command", "command": f"python3 {hooks_rel}/classify-message.py", "timeout": 15}]
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "bash .codex-vault/hooks/session-start.sh",
9
+ "command": "python3 .codex-vault/hooks/session-start.py",
10
10
  "timeout": 30
11
11
  }
12
12
  ]
@@ -273,27 +273,30 @@ def main():
273
273
 
274
274
  if parts:
275
275
  context = "\n\n".join(parts)
276
+
277
+ # Build feedback label
278
+ matched = [s for s in SIGNALS if _match(s["patterns"], prompt.lower())]
279
+ feedback_parts = []
280
+ for s in matched:
281
+ feedback_parts.append(f"{s['name']} → {s['skill']}")
282
+ if is_session_end(prompt):
283
+ feedback_parts.append("SESSION END → /wrap-up")
284
+ icon = "🔄" if mode == "auto" else "💡"
285
+ label = ", ".join(feedback_parts) if feedback_parts else "intent detected"
286
+
287
+ # Hook trigger notification
288
+ print(f" {icon} vault: {label}")
289
+
276
290
  output = {
277
291
  "hookSpecificOutput": {
278
292
  "hookEventName": "UserPromptSubmit",
279
293
  "additionalContext": context
280
- }
294
+ },
295
+ "systemMessage": f"{icon} vault: {label}"
281
296
  }
282
297
  json.dump(output, sys.stdout)
283
298
  sys.stdout.flush()
284
299
 
285
- # Visible feedback to user terminal (stderr)
286
- matched = [s for s in SIGNALS if _match(s["patterns"], prompt.lower())]
287
- parts = []
288
- for s in matched:
289
- parts.append(f"{s['name']} → {s['skill']}")
290
- if is_session_end(prompt):
291
- parts.append("SESSION END → /wrap-up")
292
- if parts:
293
- label = ", ".join(parts)
294
- icon = "🔄" if mode == "auto" else "💡"
295
- print(f" {icon} vault: {label}", file=sys.stderr)
296
-
297
300
  sys.exit(0)
298
301
 
299
302
 
@@ -355,15 +355,25 @@ def main():
355
355
  json.dump(output, sys.stdout)
356
356
  sys.exit(0)
357
357
 
358
+ # Read hook input for session metadata
359
+ try:
360
+ event = json.load(sys.stdin)
361
+ except Exception:
362
+ event = {}
363
+
358
364
  context = _build_context(vault_dir)
359
365
  banner = _build_banner(vault_dir)
360
366
 
367
+ # =================== Codex hook trigger notification ===================
368
+ print("✅ 【Hook 通知】SessionStart 已触发 — 会话启动/恢复完成")
369
+ print(f" 会话 ID: {event.get('sessionId', '未知')} | Matcher: {event.get('matcher', 'N/A')}")
370
+
361
371
  output = {
362
372
  "hookSpecificOutput": {
363
373
  "hookEventName": "SessionStart",
364
374
  "additionalContext": context
365
375
  },
366
- "systemMessage": banner
376
+ "systemMessage": "【Codex Hook】SessionStart 执行完毕 ✅\n" + banner
367
377
  }
368
378
 
369
379
  json.dump(output, sys.stdout)
@@ -94,23 +94,26 @@ def main():
94
94
 
95
95
  if warnings:
96
96
  hint_list = "\n".join(f" - {w}" for w in warnings)
97
+ count = len(warnings)
98
+ first = warnings[0]
99
+ if count == 1:
100
+ feedback = f"⚠️ vault: {basename} — {first}"
101
+ else:
102
+ feedback = f"⚠️ vault: {basename} — {first} (+{count - 1} more)"
103
+
104
+ # Hook trigger notification
105
+ print(f" {feedback}")
106
+
97
107
  output = {
98
108
  "hookSpecificOutput": {
99
109
  "hookEventName": "PostToolUse",
100
110
  "additionalContext": f"Vault warnings for `{basename}`:\n{hint_list}\nFix these before moving on."
101
- }
111
+ },
112
+ "systemMessage": feedback
102
113
  }
103
114
  json.dump(output, sys.stdout)
104
115
  sys.stdout.flush()
105
116
 
106
- # Visible feedback to user terminal (stderr)
107
- count = len(warnings)
108
- first = warnings[0]
109
- if count == 1:
110
- print(f" ⚠️ vault: {basename} — {first}", file=sys.stderr)
111
- else:
112
- print(f" ⚠️ vault: {basename} — {first} (+{count - 1} more)", file=sys.stderr)
113
-
114
117
  sys.exit(0)
115
118
 
116
119