@suwujs/codex-vault 0.4.2 → 0.5.0

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.4.2",
3
+ "version": "0.5.0",
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.4.2
1
+ 0.5.0
@@ -5,6 +5,10 @@ set -eo pipefail
5
5
  # Injects vault context into the agent's prompt at session start.
6
6
  # Works with any agent that supports SessionStart hooks (Claude Code, Codex CLI).
7
7
  #
8
+ # Output contract (matches claude-mem pattern):
9
+ # stdout → JSON with hookSpecificOutput.additionalContext (agent context)
10
+ # stderr → visible banner (user terminal)
11
+ #
8
12
  # Dynamic context: adapts git log window, reads full North Star,
9
13
  # shows all active work, and includes uncommitted changes.
10
14
 
@@ -47,9 +51,13 @@ fi
47
51
  echo ""
48
52
  } >&2
49
53
 
50
- # --- Full context (stdout agent) ---
51
- echo "## Session Context"
52
- echo ""
54
+ # --- Collect context into variable ---
55
+ CONTEXT=$(
56
+ cat <<'CONTEXT_HEADER'
57
+ ## Session Context
58
+
59
+ CONTEXT_HEADER
60
+
53
61
  echo "### Date"
54
62
  echo "$(date +%Y-%m-%d) ($(date +%A))"
55
63
  echo ""
@@ -160,11 +168,9 @@ _key_files() {
160
168
  }
161
169
 
162
170
  if [ "$FILE_COUNT" -le 20 ]; then
163
- # Tier 1: small vault — list everything
164
171
  echo "$ALL_FILES"
165
172
 
166
173
  elif [ "$FILE_COUNT" -le 50 ]; then
167
- # Tier 2: medium vault — list hot folders, summarize cold storage
168
174
  HOT_FILES=$(echo "$ALL_FILES" | grep -v -E "^\./sources/|^\./work/archive/" || true)
169
175
  COLD_COUNT=$(echo "$ALL_FILES" | grep -E "^\./sources/|^\./work/archive/" | grep -c . 2>/dev/null || echo "0")
170
176
 
@@ -177,7 +183,6 @@ elif [ "$FILE_COUNT" -le 50 ]; then
177
183
  fi
178
184
 
179
185
  elif [ "$FILE_COUNT" -le 150 ]; then
180
- # Tier 3: large vault — folder summary + recent + key files
181
186
  echo "($FILE_COUNT files — showing summary)"
182
187
  echo ""
183
188
  _folder_summary
@@ -189,7 +194,6 @@ elif [ "$FILE_COUNT" -le 150 ]; then
189
194
  _key_files
190
195
 
191
196
  else
192
- # Tier 4: very large vault — minimal footprint
193
197
  echo "($FILE_COUNT files — showing summary)"
194
198
  echo ""
195
199
  _folder_summary
@@ -202,3 +206,16 @@ else
202
206
  echo ""
203
207
  echo "Use /recall <topic> to search the vault."
204
208
  fi
209
+ )
210
+
211
+ # --- Output structured JSON (stdout → agent via hookSpecificOutput) ---
212
+ python3 -c "
213
+ import json, sys
214
+ context = sys.stdin.read()
215
+ json.dump({
216
+ 'hookSpecificOutput': {
217
+ 'hookEventName': 'SessionStart',
218
+ 'additionalContext': context
219
+ }
220
+ }, sys.stdout)
221
+ " <<< "$CONTEXT"
@@ -6,8 +6,9 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "bash .codex-vault/hooks/session-start.sh",
10
- "timeout": 30
9
+ "command": "python3 .codex-vault/hooks/session-start.py",
10
+ "timeout": 30,
11
+ "statusMessage": "📚 Codex-Vault loading..."
11
12
  }
12
13
  ]
13
14
  }
@@ -0,0 +1,379 @@
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
+ context = _build_context(vault_dir)
359
+ banner = _build_banner(vault_dir)
360
+
361
+ output = {
362
+ "hookSpecificOutput": {
363
+ "hookEventName": "SessionStart",
364
+ "additionalContext": context
365
+ },
366
+ "systemMessage": banner
367
+ }
368
+
369
+ json.dump(output, sys.stdout)
370
+ sys.stdout.flush()
371
+ sys.exit(0)
372
+
373
+
374
+ if __name__ == "__main__":
375
+ try:
376
+ main()
377
+ except Exception:
378
+ # Never block session start
379
+ sys.exit(0)
@@ -5,6 +5,10 @@ set -eo pipefail
5
5
  # Injects vault context into the agent's prompt at session start.
6
6
  # Works with any agent that supports SessionStart hooks (Claude Code, Codex CLI).
7
7
  #
8
+ # Output contract (matches claude-mem pattern):
9
+ # stdout → JSON with hookSpecificOutput.additionalContext (agent context)
10
+ # stderr → visible banner (user terminal)
11
+ #
8
12
  # Dynamic context: adapts git log window, reads full North Star,
9
13
  # shows all active work, and includes uncommitted changes.
10
14
 
@@ -47,9 +51,13 @@ fi
47
51
  echo ""
48
52
  } >&2
49
53
 
50
- # --- Full context (stdout agent) ---
51
- echo "## Session Context"
52
- echo ""
54
+ # --- Collect context into variable ---
55
+ CONTEXT=$(
56
+ cat <<'CONTEXT_HEADER'
57
+ ## Session Context
58
+
59
+ CONTEXT_HEADER
60
+
53
61
  echo "### Date"
54
62
  echo "$(date +%Y-%m-%d) ($(date +%A))"
55
63
  echo ""
@@ -160,11 +168,9 @@ _key_files() {
160
168
  }
161
169
 
162
170
  if [ "$FILE_COUNT" -le 20 ]; then
163
- # Tier 1: small vault — list everything
164
171
  echo "$ALL_FILES"
165
172
 
166
173
  elif [ "$FILE_COUNT" -le 50 ]; then
167
- # Tier 2: medium vault — list hot folders, summarize cold storage
168
174
  HOT_FILES=$(echo "$ALL_FILES" | grep -v -E "^\./sources/|^\./work/archive/" || true)
169
175
  COLD_COUNT=$(echo "$ALL_FILES" | grep -E "^\./sources/|^\./work/archive/" | grep -c . 2>/dev/null || echo "0")
170
176
 
@@ -177,7 +183,6 @@ elif [ "$FILE_COUNT" -le 50 ]; then
177
183
  fi
178
184
 
179
185
  elif [ "$FILE_COUNT" -le 150 ]; then
180
- # Tier 3: large vault — folder summary + recent + key files
181
186
  echo "($FILE_COUNT files — showing summary)"
182
187
  echo ""
183
188
  _folder_summary
@@ -189,7 +194,6 @@ elif [ "$FILE_COUNT" -le 150 ]; then
189
194
  _key_files
190
195
 
191
196
  else
192
- # Tier 4: very large vault — minimal footprint
193
197
  echo "($FILE_COUNT files — showing summary)"
194
198
  echo ""
195
199
  _folder_summary
@@ -202,3 +206,16 @@ else
202
206
  echo ""
203
207
  echo "Use /recall <topic> to search the vault."
204
208
  fi
209
+ )
210
+
211
+ # --- Output structured JSON (stdout → agent via hookSpecificOutput) ---
212
+ python3 -c "
213
+ import json, sys
214
+ context = sys.stdin.read()
215
+ json.dump({
216
+ 'hookSpecificOutput': {
217
+ 'hookEventName': 'SessionStart',
218
+ 'additionalContext': context
219
+ }
220
+ }, sys.stdout)
221
+ " <<< "$CONTEXT"