opencode-claude-memory 1.7.6 → 1.7.8

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.
Files changed (69) hide show
  1. package/README.md +104 -213
  2. package/dist/agents.d.ts +12 -0
  3. package/dist/agents.js +69 -0
  4. package/dist/config.d.ts +112 -0
  5. package/dist/config.js +79 -0
  6. package/dist/extraction/ExtractionCoordinator.d.ts +62 -0
  7. package/dist/extraction/ExtractionCoordinator.js +386 -0
  8. package/dist/extraction/autodream.d.ts +33 -0
  9. package/dist/extraction/autodream.js +85 -0
  10. package/dist/extraction/forkSession.d.ts +37 -0
  11. package/dist/extraction/forkSession.js +133 -0
  12. package/dist/extraction/lock.d.ts +29 -0
  13. package/dist/extraction/lock.js +191 -0
  14. package/dist/extraction/prompts.d.ts +5 -0
  15. package/dist/extraction/prompts.js +78 -0
  16. package/dist/extraction/state.d.ts +38 -0
  17. package/dist/extraction/state.js +159 -0
  18. package/dist/hooks/ignore.d.ts +6 -0
  19. package/dist/hooks/ignore.js +53 -0
  20. package/dist/hooks/messages.d.ts +14 -0
  21. package/dist/hooks/messages.js +96 -0
  22. package/dist/index.d.ts +7 -1
  23. package/dist/index.js +50 -633
  24. package/dist/prompt/sections.d.ts +7 -0
  25. package/dist/{prompt.js → prompt/sections.js} +16 -72
  26. package/dist/prompt/systemPrompt.d.ts +7 -0
  27. package/dist/prompt/systemPrompt.js +64 -0
  28. package/dist/recall/RecallCoordinator.d.ts +36 -0
  29. package/dist/recall/RecallCoordinator.js +140 -0
  30. package/dist/recall/format.d.ts +17 -0
  31. package/dist/{recall.js → recall/format.js} +13 -36
  32. package/dist/{recallSelector.d.ts → recall/selector.d.ts} +26 -16
  33. package/dist/recall/selector.js +92 -0
  34. package/dist/sdk.d.ts +12 -0
  35. package/dist/sdk.js +8 -0
  36. package/dist/store/MemoryStore.d.ts +41 -0
  37. package/dist/store/MemoryStore.js +105 -0
  38. package/dist/store/frontmatter.d.ts +25 -0
  39. package/dist/store/frontmatter.js +61 -0
  40. package/dist/store/indexFile.d.ts +15 -0
  41. package/dist/store/indexFile.js +153 -0
  42. package/dist/{paths.d.ts → store/paths.d.ts} +7 -6
  43. package/dist/store/paths.js +187 -0
  44. package/dist/store/scan.d.ts +29 -0
  45. package/dist/store/scan.js +112 -0
  46. package/dist/tools.d.ts +9 -0
  47. package/dist/tools.js +142 -0
  48. package/dist/util/exclusiveFile.d.ts +11 -0
  49. package/dist/util/exclusiveFile.js +108 -0
  50. package/dist/util/legacyShellHook.d.ts +3 -0
  51. package/dist/util/legacyShellHook.js +23 -0
  52. package/dist/util/log.d.ts +6 -0
  53. package/dist/util/log.js +29 -0
  54. package/dist/util/ownedSessions.d.ts +7 -0
  55. package/dist/util/ownedSessions.js +39 -0
  56. package/dist/util/timeout.d.ts +5 -0
  57. package/dist/util/timeout.js +39 -0
  58. package/package.json +15 -8
  59. package/bin/opencode-memory +0 -1436
  60. package/dist/memory.d.ts +0 -25
  61. package/dist/memory.js +0 -203
  62. package/dist/memoryScan.d.ts +0 -20
  63. package/dist/memoryScan.js +0 -106
  64. package/dist/nativeExtraction.d.ts +0 -2
  65. package/dist/nativeExtraction.js +0 -38
  66. package/dist/paths.js +0 -141
  67. package/dist/prompt.d.ts +0 -4
  68. package/dist/recall.d.ts +0 -12
  69. package/dist/recallSelector.js +0 -154
@@ -1,1436 +0,0 @@
1
- #!/usr/bin/env bash
2
- #
3
- # opencode-memory — Wrapper for OpenCode with post-session memory maintenance.
4
- #
5
- # Installs a shell hook (function) that intercepts the `opencode` command,
6
- # then wraps the real binary with post-session extraction and auto-dream.
7
- #
8
- # Subcommands:
9
- # opencode-memory install — Install shell hook to ~/.zshrc or ~/.bashrc
10
- # opencode-memory uninstall — Remove shell hook
11
- # opencode-memory self -v — Print the wrapper package version
12
- # opencode-memory [args...] — Run opencode with post-session memory maintenance
13
- #
14
- # How it works:
15
- # 1. Shell hook defines `opencode()` function that delegates to `opencode-memory`
16
- # 2. `opencode-memory` finds the real `opencode` binary in PATH
17
- # 3. Runs it normally with all your arguments
18
- # 4. After you exit, finds the most recent session
19
- # 5. Optionally runs memory extraction (conversation -> memories)
20
- # 6. Optionally runs auto-dream consolidation (memory pruning/merge)
21
- #
22
- # Requirements:
23
- # - Real `opencode` CLI reachable in PATH
24
- # - `jq` for auto-dream gate/session counting
25
- # - The opencode-memory plugin installed (provides memory_* tools)
26
- #
27
- # Environment variables:
28
- # OPENCODE_MEMORY_EXTRACT=0 — Disable post-session extraction
29
- # OPENCODE_MEMORY_FOREGROUND=1 — Run maintenance in foreground (debug)
30
- # OPENCODE_MEMORY_TERMINAL_LOG=1|0 — Force-enable/disable terminal logs (default: foreground only)
31
- # OPENCODE_MEMORY_MODEL=... — Extraction model override
32
- # OPENCODE_MEMORY_AGENT=... — Extraction agent override
33
- # OPENCODE_MEMORY_AUTODREAM=0 — Disable auto-dream consolidation
34
- # OPENCODE_MEMORY_AUTODREAM_MIN_HOURS=24 — Min hours between auto-dream runs
35
- # OPENCODE_MEMORY_AUTODREAM_MIN_SESSIONS=5 — Min touched sessions since last consolidation
36
- # OPENCODE_MEMORY_AUTODREAM_MODEL=... — Auto-dream model override
37
- # OPENCODE_MEMORY_AUTODREAM_AGENT=... — Auto-dream agent override
38
- # OPENCODE_MEMORY_DIR=... — Override working directory for opencode
39
- #
40
-
41
- set -euo pipefail
42
-
43
- resolve_script_path() {
44
- if command -v python3 >/dev/null 2>&1; then
45
- python3 - "${BASH_SOURCE[0]}" <<'PY'
46
- import os
47
- import sys
48
-
49
- print(os.path.realpath(sys.argv[1]))
50
- PY
51
- return 0
52
- fi
53
-
54
- printf '%s\n' "${BASH_SOURCE[0]}"
55
- }
56
-
57
- SCRIPT_PATH="$(resolve_script_path)"
58
- SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)"
59
- PACKAGE_JSON="$SCRIPT_DIR/../package.json"
60
-
61
- resolve_package_json() {
62
- if [ -f "$PACKAGE_JSON" ]; then
63
- printf '%s\n' "$PACKAGE_JSON"
64
- return 0
65
- fi
66
-
67
- if command -v npm >/dev/null 2>&1; then
68
- local npm_root
69
- npm_root=$(npm root -g 2>/dev/null || true)
70
- if [ -n "$npm_root" ] && [ -f "$npm_root/opencode-claude-memory/package.json" ]; then
71
- printf '%s\n' "$npm_root/opencode-claude-memory/package.json"
72
- return 0
73
- fi
74
- fi
75
-
76
- printf '%s\n' "$PACKAGE_JSON"
77
- }
78
-
79
- print_wrapper_version() {
80
- local version
81
- local package_json
82
-
83
- package_json="$(resolve_package_json)"
84
-
85
- version=$(awk -F'"' '/^[[:space:]]*"version"[[:space:]]*:/ { print $4; exit }' "$package_json")
86
-
87
- if [ -z "$version" ]; then
88
- echo "[opencode-memory] ERROR: Cannot read package version from $package_json" >&2
89
- exit 1
90
- fi
91
-
92
- printf '%s\n' "$version"
93
- }
94
-
95
- # ============================================================================
96
- # Shell Hook Management
97
- # ============================================================================
98
-
99
- HOOK_START_MARKER='# >>> opencode-memory auto-initialization >>>'
100
- HOOK_END_MARKER='# <<< opencode-memory auto-initialization <<<'
101
-
102
- detect_shell_rc() {
103
- local shell_name
104
- shell_name="$(basename "${SHELL:-}")"
105
-
106
- case "$shell_name" in
107
- zsh)
108
- echo "$HOME/.zshrc"
109
- ;;
110
- bash)
111
- echo "$HOME/.bashrc"
112
- ;;
113
- *)
114
- if [ -f "$HOME/.zshrc" ]; then
115
- echo "$HOME/.zshrc"
116
- elif [ -f "$HOME/.bashrc" ]; then
117
- echo "$HOME/.bashrc"
118
- else
119
- echo "$HOME/.zshrc"
120
- fi
121
- ;;
122
- esac
123
- }
124
-
125
- install_hook() {
126
- local rc_file
127
- rc_file=$(detect_shell_rc)
128
-
129
- if grep -qF "$HOOK_START_MARKER" "$rc_file" 2>/dev/null; then
130
- echo "[opencode-memory] Hook already installed in $rc_file"
131
- return 0
132
- fi
133
-
134
- cat >> "$rc_file" << 'HOOK'
135
-
136
- # >>> opencode-memory auto-initialization >>>
137
- opencode() {
138
- command opencode-memory "$@"
139
- }
140
- # <<< opencode-memory auto-initialization <<<
141
- HOOK
142
-
143
- echo "[opencode-memory] Shell hook installed in $rc_file"
144
- echo "[opencode-memory] Restart your shell or run: source $rc_file"
145
- }
146
-
147
- remove_hook_from_rc() {
148
- local rc_file="$1"
149
- local tmp_file
150
- tmp_file=$(mktemp)
151
-
152
- awk -v start="$HOOK_START_MARKER" -v end="$HOOK_END_MARKER" '
153
- $0 == start { skip=1; next }
154
- $0 == end { skip=0; next }
155
- !skip
156
- ' "$rc_file" > "$tmp_file"
157
-
158
- mv "$tmp_file" "$rc_file"
159
- }
160
-
161
- uninstall_hook() {
162
- local removed=0
163
- local rc_file
164
-
165
- for rc_file in "$HOME/.zshrc" "$HOME/.bashrc"; do
166
- [ -f "$rc_file" ] || continue
167
-
168
- if grep -qF "$HOOK_START_MARKER" "$rc_file" 2>/dev/null; then
169
- remove_hook_from_rc "$rc_file"
170
- echo "[opencode-memory] Shell hook removed from $rc_file"
171
- removed=1
172
- fi
173
- done
174
-
175
- if [ "$removed" -eq 0 ]; then
176
- rc_file=$(detect_shell_rc)
177
- echo "[opencode-memory] Hook not found in $rc_file"
178
- return 0
179
- fi
180
-
181
- echo "[opencode-memory] Restart your shell or run: source <your rc file>"
182
- }
183
-
184
- # Handle subcommands before any opencode resolution
185
- case "${1:-}" in
186
- install)
187
- install_hook
188
- exit 0
189
- ;;
190
- uninstall)
191
- uninstall_hook
192
- exit 0
193
- ;;
194
- self)
195
- case "${2:-}" in
196
- -v|--version|version)
197
- print_wrapper_version
198
- exit 0
199
- ;;
200
- esac
201
- ;;
202
- esac
203
-
204
- # ============================================================================
205
- # Resolve the real opencode binary
206
- # ============================================================================
207
-
208
- find_real_opencode() {
209
- # Since this script is named `opencode-memory` (not `opencode`),
210
- # `command -v opencode` finds the real binary without ambiguity.
211
- local real
212
- real=$(command -v opencode 2>/dev/null) || true
213
- if [ -z "$real" ] || [ ! -x "$real" ]; then
214
- echo "[opencode-memory] ERROR: Cannot find opencode binary in PATH" >&2
215
- echo "[opencode-memory] Make sure opencode is installed: https://opencode.ai" >&2
216
- exit 1
217
- fi
218
- echo "$real"
219
- }
220
-
221
- REAL_OPENCODE="$(find_real_opencode)"
222
-
223
- is_opencode_subcommand() {
224
- case "$1" in
225
- completion|acp|mcp|attach|run|debug|providers|auth|agent|upgrade|uninstall|serve|web|models|stats|export|import|github|pr|session|plugin|plug|db)
226
- return 0
227
- ;;
228
- *)
229
- return 1
230
- ;;
231
- esac
232
- }
233
-
234
- extract_working_dir_from_args() {
235
- local previous=""
236
- for arg in "$@"; do
237
- if [ "$previous" = "--dir" ]; then
238
- printf '%s\n' "$arg"
239
- return 0
240
- fi
241
- previous="$arg"
242
- done
243
-
244
- if [ "$#" -gt 0 ] && [ -n "${1:-}" ] && [[ "${1:-}" != -* ]] && ! is_opencode_subcommand "$1" && [ -d "$1" ]; then
245
- printf '%s\n' "$1"
246
- return 0
247
- fi
248
-
249
- return 1
250
- }
251
-
252
- # ============================================================================
253
- # Configuration
254
- # ============================================================================
255
-
256
- EXTRACT_ENABLED="${OPENCODE_MEMORY_EXTRACT:-1}"
257
- FOREGROUND="${OPENCODE_MEMORY_FOREGROUND:-0}"
258
- LOG_ENABLED="${OPENCODE_MEMORY_TERMINAL_LOG:-}"
259
- EXTRACT_MODEL="${OPENCODE_MEMORY_MODEL:-}"
260
- EXTRACT_AGENT="${OPENCODE_MEMORY_AGENT:-}"
261
-
262
- AUTODREAM_ENABLED="${OPENCODE_MEMORY_AUTODREAM:-1}"
263
- AUTODREAM_MIN_HOURS="${OPENCODE_MEMORY_AUTODREAM_MIN_HOURS:-24}"
264
- AUTODREAM_MIN_SESSIONS="${OPENCODE_MEMORY_AUTODREAM_MIN_SESSIONS:-5}"
265
- AUTODREAM_SCAN_LIMIT="${OPENCODE_MEMORY_AUTODREAM_SCAN_LIMIT:-200}"
266
- AUTODREAM_MODEL="${OPENCODE_MEMORY_AUTODREAM_MODEL:-$EXTRACT_MODEL}"
267
- AUTODREAM_AGENT="${OPENCODE_MEMORY_AUTODREAM_AGENT:-$EXTRACT_AGENT}"
268
- AUTODREAM_STALE_LOCK_SECS=$((60 * 60))
269
- SESSION_WAIT_SECONDS="${OPENCODE_MEMORY_SESSION_WAIT_SECONDS:-5}"
270
-
271
- WORKING_DIR="${OPENCODE_MEMORY_DIR:-$(extract_working_dir_from_args "$@" || pwd)}"
272
-
273
- TMP_BASE_DIR="${TMPDIR:-/tmp}"
274
- while [ "$TMP_BASE_DIR" != "/" ] && [ "${TMP_BASE_DIR%/}" != "$TMP_BASE_DIR" ]; do
275
- TMP_BASE_DIR="${TMP_BASE_DIR%/}"
276
- done
277
- if [ -z "$TMP_BASE_DIR" ]; then
278
- TMP_BASE_DIR="/"
279
- fi
280
-
281
- # Scope lock files at project root granularity (not per-subdirectory).
282
- PROJECT_SCOPE_DIR="$WORKING_DIR"
283
- if git -C "$WORKING_DIR" rev-parse --show-toplevel >/dev/null 2>&1; then
284
- PROJECT_SCOPE_DIR="$(git -C "$WORKING_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$WORKING_DIR")"
285
- fi
286
-
287
- PROJECT_KEY="$(printf '%s' "$PROJECT_SCOPE_DIR" | cksum | awk '{print $1}')"
288
-
289
- # Lock files (prevent concurrent work on the same project)
290
- LOCK_DIR="$TMP_BASE_DIR/opencode-memory-locks"
291
- mkdir -p "$LOCK_DIR"
292
- EXTRACT_LOCK_FILE="$LOCK_DIR/${PROJECT_KEY}.extract.lock"
293
-
294
- STATE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/opencode-memory"
295
- mkdir -p "$STATE_DIR"
296
- CONSOLIDATION_LOCK_FILE="$STATE_DIR/${PROJECT_KEY}.consolidate-lock"
297
-
298
- # Logs
299
- LOG_DIR="$TMP_BASE_DIR/opencode-memory-logs"
300
- mkdir -p "$LOG_DIR"
301
- TASK_LOG_PREFIX="$(date +%Y%m%d-%H%M%S)-${PROJECT_KEY}"
302
- EXTRACT_LOG_FILE="$LOG_DIR/extract-${TASK_LOG_PREFIX}.log"
303
- AUTODREAM_LOG_FILE="$LOG_DIR/dream-${TASK_LOG_PREFIX}.log"
304
-
305
- # ============================================================================
306
- # Prompts
307
- # ============================================================================
308
-
309
- # Adapted from Claude Code's extraction prompt, simplified for OpenCode's
310
- # headless run mode. The model sees the full conversation context via --fork.
311
- EXTRACT_PROMPT='You are now acting as the memory extraction subagent. Review the entire conversation above and extract any information worth remembering for future sessions.
312
-
313
- ## What to save
314
-
315
- Use the `memory_save` tool to persist memories. There are four types:
316
-
317
- 1. **user** — Who the user is: role, expertise, preferences, communication style. Helps tailor future interactions.
318
- 2. **feedback** — Guidance on how to work: corrections ("don'\''t do X"), confirmations ("yes, keep doing that"), approach preferences. Include *why* so edge cases can be judged.
319
- 3. **project** — Ongoing work context: goals, deadlines, initiatives, decisions, bugs. NOT derivable from code/git. Convert relative dates to absolute.
320
- 4. **reference** — Pointers to external resources: URLs, tool names, where to find information outside the codebase.
321
-
322
- ## What NOT to save
323
-
324
- - Code patterns, architecture, file structure — derivable from the codebase
325
- - Git history, recent changes — use `git log`/`git blame`
326
- - Debugging solutions — the fix is in the code
327
- - Anything already in AGENTS.md / project config files
328
- - Ephemeral task details or current conversation context
329
- - Information that was already saved in a previous extraction
330
-
331
- ## How to save
332
-
333
- For each memory worth saving, call `memory_save` with:
334
- - `file_name`: descriptive slug (e.g., `user_role`, `feedback_testing_approach`)
335
- - `name`: short title
336
- - `description`: one-line description (used for relevance matching in future sessions)
337
- - `type`: one of user, feedback, project, reference
338
- - `content`: the memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines.
339
-
340
- ## Instructions
341
-
342
- 1. Analyze the conversation for memorable information
343
- 2. Check existing memories first (use `memory_list`) to avoid duplicates — update existing ones if needed
344
- 3. Save each distinct memory as a separate entry
345
- 4. If the conversation was trivial (e.g., just "hello" or a quick lookup), save nothing — that'\''s fine
346
- 5. Be selective: 0-3 memories per session is typical. Quality over quantity.
347
- 6. Do NOT save a memory about the extraction process itself.'
348
-
349
- # Periodic memory consolidation inspired by Claude Code auto-dream.
350
- AUTODREAM_PROMPT="$(cat <<'EOF'
351
- You are performing an auto-dream memory consolidation pass.
352
-
353
- Goal: tighten and de-duplicate memory files so future sessions can orient faster.
354
-
355
- ## Available tools
356
- - memory_list
357
- - memory_search
358
- - memory_read
359
- - memory_save
360
- - memory_delete
361
-
362
- ## Phase 1 — Orient
363
- 1. Use memory_list to inspect current memory inventory.
364
- 2. Identify overlapping or stale entries that can be merged/updated/deleted.
365
-
366
- ## Phase 2 — Consolidate
367
- 1. Merge duplicates into a single stronger memory using memory_save.
368
- 2. Rewrite vague descriptions so retrieval is easier and more precise.
369
- 3. For feedback/project entries, ensure content is structured as:
370
- - main rule/fact
371
- - **Why:**
372
- - **How to apply:**
373
-
374
- ## Phase 3 — Prune
375
- 1. Delete memories that are clearly obsolete, contradictory, or low-value.
376
- 2. Keep total memory set concise and high signal.
377
-
378
- ## Guardrails
379
- - Do NOT invent facts.
380
- - If confidence is low, keep existing memory instead of guessing.
381
- - If memory quality is already strong, make no changes and explicitly say so.
382
-
383
- Return a short summary of what you updated, merged, or removed.
384
- EOF
385
- )"
386
-
387
- # ============================================================================
388
- # Helper Functions
389
- # ============================================================================
390
-
391
- should_log() {
392
- if [ -n "$LOG_ENABLED" ]; then
393
- [ "$LOG_ENABLED" = "1" ]
394
- return $?
395
- fi
396
-
397
- [ "$FOREGROUND" = "1" ]
398
- }
399
-
400
- log() {
401
- should_log || return 0
402
- echo "[opencode-memory] $*" >&2
403
- }
404
-
405
- is_positive_int() {
406
- [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ]
407
- }
408
-
409
- is_bool_flag() {
410
- [ "$1" = "0" ] || [ "$1" = "1" ]
411
- }
412
-
413
- if [ -n "$LOG_ENABLED" ] && ! is_bool_flag "$LOG_ENABLED"; then
414
- echo "[opencode-memory] Invalid OPENCODE_MEMORY_TERMINAL_LOG=$LOG_ENABLED, expected 0 or 1; defaulting to foreground-only logging" >&2
415
- LOG_ENABLED=""
416
- fi
417
-
418
- if ! is_positive_int "$AUTODREAM_MIN_HOURS"; then
419
- log "Invalid OPENCODE_MEMORY_AUTODREAM_MIN_HOURS=$AUTODREAM_MIN_HOURS, using default 24"
420
- AUTODREAM_MIN_HOURS=24
421
- fi
422
- if ! is_positive_int "$AUTODREAM_MIN_SESSIONS"; then
423
- log "Invalid OPENCODE_MEMORY_AUTODREAM_MIN_SESSIONS=$AUTODREAM_MIN_SESSIONS, using default 5"
424
- AUTODREAM_MIN_SESSIONS=5
425
- fi
426
- if ! is_positive_int "$AUTODREAM_SCAN_LIMIT"; then
427
- log "Invalid OPENCODE_MEMORY_AUTODREAM_SCAN_LIMIT=$AUTODREAM_SCAN_LIMIT, using default 200"
428
- AUTODREAM_SCAN_LIMIT=200
429
- fi
430
-
431
- has_new_memories() {
432
- # Check if any memory file was modified during the session.
433
- local mem_base="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects"
434
- if [ ! -d "$mem_base" ]; then
435
- return 1
436
- fi
437
-
438
- local newer_files
439
- newer_files=$(find "$mem_base" -path "*/memory/*.md" -newer "$TIMESTAMP_FILE" 2>/dev/null | head -1)
440
- [ -n "$newer_files" ]
441
- }
442
-
443
- cleanup_timestamp() {
444
- rm -f "$TIMESTAMP_FILE" "${TRANSCRIPT_CHECKPOINT_FILE:-}"
445
- }
446
-
447
- get_session_list_json() {
448
- local limit="$1"
449
- local output
450
-
451
- if output=$("$REAL_OPENCODE" session list --format json -n "$limit" 2>/dev/null); then
452
- echo "$output"
453
- return 0
454
- fi
455
-
456
- if output=$("$REAL_OPENCODE" session list --format json 2>/dev/null); then
457
- echo "$output"
458
- return 0
459
- fi
460
-
461
- return 1
462
- }
463
-
464
- get_latest_session_id() {
465
- local session_json
466
- session_json=$(get_session_list_json 1) || return 1
467
-
468
- # Parse with jq if available, fallback to grep.
469
- if command -v jq &>/dev/null; then
470
- echo "$session_json" | jq -r '.[0].id // empty'
471
- else
472
- echo "$session_json" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/'
473
- fi
474
- }
475
-
476
- get_opencode_db_path() {
477
- printf '%s\n' "$HOME/.local/share/opencode/opencode.db"
478
- }
479
-
480
- get_session_title_from_db() {
481
- local session_id="$1"
482
- local db_path
483
- db_path=$(get_opencode_db_path)
484
-
485
- if [ -z "$session_id" ] || [ ! -f "$db_path" ] || ! command -v python3 >/dev/null 2>&1; then
486
- return 1
487
- fi
488
-
489
- python3 - "$db_path" "$session_id" <<'PY'
490
- import sqlite3
491
- import sys
492
-
493
- db_path, session_id = sys.argv[1:3]
494
-
495
- try:
496
- conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
497
- row = conn.execute("SELECT title FROM session WHERE id = ? LIMIT 1", (session_id,)).fetchone()
498
- except Exception:
499
- raise SystemExit(1)
500
- finally:
501
- try:
502
- conn.close()
503
- except Exception:
504
- pass
505
-
506
- if row and row[0]:
507
- print(row[0])
508
- PY
509
- }
510
-
511
- get_session_target_id() {
512
- local before_json="$1"
513
- local started_at_ms="$2"
514
- local workdir="$3"
515
- local project_dir="$4"
516
- local allow_existing_fallback="${5:-1}"
517
- local after_json
518
-
519
- after_json=$(get_session_list_json "$AUTODREAM_SCAN_LIMIT") || return 1
520
-
521
- if command -v python3 >/dev/null 2>&1; then
522
- python3 - "$before_json" "$after_json" "$started_at_ms" "$workdir" "$project_dir" "$allow_existing_fallback" <<'PY'
523
- import json
524
- import os
525
- import sys
526
-
527
- before_raw, after_raw, started_at_ms_raw, workdir, project_dir, allow_existing_fallback_raw = sys.argv[1:7]
528
-
529
- def parse(raw):
530
- try:
531
- data = json.loads(raw)
532
- return data if isinstance(data, list) else []
533
- except Exception:
534
- return []
535
-
536
- def timestamp(item):
537
- time_obj = item.get("time") if isinstance(item.get("time"), dict) else {}
538
- for key in ("updated", "created"):
539
- value = item.get(key)
540
- if value is not None:
541
- try:
542
- return int(value)
543
- except Exception:
544
- pass
545
- value = time_obj.get(key)
546
- if value is not None:
547
- try:
548
- return int(value)
549
- except Exception:
550
- pass
551
- return 0
552
-
553
- def normalize(path):
554
- if not path:
555
- return ""
556
- return os.path.realpath(path)
557
-
558
- before = parse(before_raw)
559
- after = parse(after_raw)
560
- started_at_ms = int(started_at_ms_raw or "0")
561
- allow_existing_fallback = allow_existing_fallback_raw == "1"
562
- before_ids = {item.get("id") for item in before if item.get("id")}
563
- workdir = normalize(workdir)
564
- project_dir = normalize(project_dir)
565
-
566
- def in_scope(item):
567
- directory = normalize(item.get("directory", ""))
568
- return directory != "" and directory in {workdir, project_dir}
569
-
570
- def choose(candidates):
571
- ranked = sorted(
572
- [item for item in candidates if item.get("id")],
573
- key=lambda item: timestamp(item),
574
- reverse=True,
575
- )
576
- if ranked:
577
- print(ranked[0]["id"])
578
- return True
579
- return False
580
-
581
- new_sessions = [item for item in after if item.get("id") not in before_ids]
582
- updated_sessions = [item for item in after if timestamp(item) > started_at_ms]
583
-
584
- candidate_pools = [
585
- [item for item in new_sessions if in_scope(item)],
586
- [item for item in updated_sessions if in_scope(item)],
587
- ]
588
-
589
- if allow_existing_fallback:
590
- candidate_pools.append([item for item in after if in_scope(item)])
591
-
592
- for pool in candidate_pools:
593
- if choose(pool):
594
- break
595
- PY
596
- return 0
597
- fi
598
-
599
- get_latest_session_id
600
- }
601
-
602
- get_transcripts_dir() {
603
- printf '%s\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/transcripts"
604
- }
605
-
606
- get_storage_session_diff_dir() {
607
- printf '%s\n' "$HOME/.local/share/opencode/storage/session_diff"
608
- }
609
-
610
- resolve_session_directory() {
611
- local session_id="$1"
612
- local output_file
613
-
614
- output_file=$(mktemp)
615
- if ! "$REAL_OPENCODE" export "$session_id" >"$output_file" 2>/dev/null; then
616
- rm -f "$output_file"
617
- return 1
618
- fi
619
-
620
- if [ ! -s "$output_file" ]; then
621
- rm -f "$output_file"
622
- return 1
623
- fi
624
-
625
- if command -v python3 >/dev/null 2>&1; then
626
- python3 - "$output_file" <<'PY'
627
- import json
628
- from pathlib import Path
629
- import sys
630
-
631
- raw = Path(sys.argv[1]).read_text()
632
- start = raw.find('{')
633
- if start == -1:
634
- raise SystemExit(1)
635
-
636
- try:
637
- data = json.loads(raw[start:])
638
- except Exception:
639
- raise SystemExit(1)
640
-
641
- directory = ((data.get("info") or {}).get("directory") or "")
642
- if directory:
643
- print(directory)
644
- PY
645
- local status=$?
646
- rm -f "$output_file"
647
- return $status
648
- fi
649
-
650
- rm -f "$output_file"
651
- return 1
652
- }
653
-
654
- get_scoped_artifact_session_id_since() {
655
- local timestamp_file="$1"
656
- local workdir="$2"
657
- local project_dir="$3"
658
-
659
- if ! command -v python3 >/dev/null 2>&1; then
660
- return 1
661
- fi
662
-
663
- local candidates
664
- candidates=$(python3 - "$timestamp_file" "$HOME" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" <<'PY'
665
- import os
666
- import sys
667
-
668
- timestamp_file, home_dir, claude_dir = sys.argv[1:4]
669
-
670
- try:
671
- threshold = os.path.getmtime(timestamp_file)
672
- except OSError:
673
- raise SystemExit(1)
674
-
675
- sources = [
676
- (os.path.join(home_dir, '.local', 'share', 'opencode', 'storage', 'session_diff'), '.json'),
677
- (os.path.join(claude_dir, 'transcripts'), '.jsonl'),
678
- ]
679
-
680
- latest = {}
681
- for directory, suffix in sources:
682
- if not os.path.isdir(directory):
683
- continue
684
- for entry in os.scandir(directory):
685
- if not entry.is_file() or not entry.name.endswith(suffix):
686
- continue
687
- try:
688
- mtime = entry.stat().st_mtime
689
- except OSError:
690
- continue
691
- if mtime <= threshold:
692
- continue
693
- session_id = entry.name[:-len(suffix)]
694
- latest[session_id] = max(latest.get(session_id, -1), mtime)
695
-
696
- for session_id, mtime in sorted(latest.items(), key=lambda item: item[1], reverse=True):
697
- print(session_id)
698
- PY
699
- ) || return 1
700
-
701
- local session_id=""
702
- local session_dir=""
703
- local normalized_session_dir=""
704
- local normalized_workdir=""
705
- local normalized_project_dir=""
706
- normalized_workdir=$(python3 - "$workdir" <<'PY'
707
- import os, sys
708
- print(os.path.realpath(sys.argv[1]))
709
- PY
710
- )
711
- normalized_project_dir=$(python3 - "$project_dir" <<'PY'
712
- import os, sys
713
- print(os.path.realpath(sys.argv[1]))
714
- PY
715
- )
716
-
717
- while IFS= read -r session_id; do
718
- [ -n "$session_id" ] || continue
719
- session_dir=$(resolve_session_directory "$session_id" || true)
720
- [ -n "$session_dir" ] || continue
721
- normalized_session_dir=$(python3 - "$session_dir" <<'PY'
722
- import os, sys
723
- print(os.path.realpath(sys.argv[1]))
724
- PY
725
- )
726
- if [ "$normalized_session_dir" = "$normalized_workdir" ] || [ "$normalized_session_dir" = "$normalized_project_dir" ]; then
727
- printf '%s\n' "$session_id"
728
- return 0
729
- fi
730
- done <<EOF
731
- $candidates
732
- EOF
733
-
734
- return 1
735
- }
736
-
737
- get_latest_storage_session_id_since() {
738
- local timestamp_file="$1"
739
- local storage_dir
740
- storage_dir=$(get_storage_session_diff_dir)
741
-
742
- if [ ! -d "$storage_dir" ]; then
743
- return 1
744
- fi
745
-
746
- if command -v python3 >/dev/null 2>&1; then
747
- python3 - "$storage_dir" "$timestamp_file" <<'PY'
748
- import os
749
- import sys
750
-
751
- storage_dir, timestamp_file = sys.argv[1:3]
752
-
753
- try:
754
- threshold = os.path.getmtime(timestamp_file)
755
- except OSError:
756
- raise SystemExit(1)
757
-
758
- latest = None
759
- latest_mtime = -1.0
760
-
761
- for entry in os.scandir(storage_dir):
762
- if not entry.is_file() or not entry.name.endswith('.json'):
763
- continue
764
- try:
765
- mtime = entry.stat().st_mtime
766
- except OSError:
767
- continue
768
- if mtime <= threshold:
769
- continue
770
- if mtime > latest_mtime:
771
- latest_mtime = mtime
772
- latest = entry.name[:-5]
773
-
774
- if latest:
775
- print(latest)
776
- PY
777
- return 0
778
- fi
779
-
780
- return 1
781
- }
782
-
783
- get_latest_transcript_session_id_since() {
784
- local timestamp_file="$1"
785
- local transcripts_dir
786
- transcripts_dir=$(get_transcripts_dir)
787
-
788
- if [ ! -d "$transcripts_dir" ]; then
789
- return 1
790
- fi
791
-
792
- if command -v python3 >/dev/null 2>&1; then
793
- python3 - "$transcripts_dir" "$timestamp_file" <<'PY'
794
- import os
795
- import sys
796
-
797
- transcripts_dir, timestamp_file = sys.argv[1:3]
798
-
799
- try:
800
- threshold = os.path.getmtime(timestamp_file)
801
- except OSError:
802
- raise SystemExit(1)
803
-
804
- latest = None
805
- latest_mtime = -1.0
806
-
807
- for entry in os.scandir(transcripts_dir):
808
- if not entry.is_file() or not entry.name.endswith('.jsonl'):
809
- continue
810
- try:
811
- mtime = entry.stat().st_mtime
812
- except OSError:
813
- continue
814
- if mtime <= threshold:
815
- continue
816
- if mtime > latest_mtime:
817
- latest_mtime = mtime
818
- latest = entry.name[:-6]
819
-
820
- if latest:
821
- print(latest)
822
- PY
823
- return 0
824
- fi
825
-
826
- return 1
827
- }
828
-
829
- main_prompt_requests_ignore_memory() {
830
- if [ "${1:-}" != "run" ]; then
831
- return 1
832
- fi
833
-
834
- local joined
835
- joined=$(printf '%s\n' "$*" | tr '[:upper:]' '[:lower:]')
836
- printf '%s\n' "$joined" | grep -Eq "(ignore|don't use|do not use|without|skip)[[:space:]]+(the[[:space:]]+)?memory|memory[[:space:]]+((should|must)[[:space:]]+be[[:space:]]+)?ignored"
837
- }
838
-
839
- wait_for_scoped_session_id_since() {
840
- local before_json="$1"
841
- local started_at_ms="$2"
842
- local timestamp_file="$3"
843
- local wait_seconds="${4:-5}"
844
- local allow_existing_fallback="${5:-1}"
845
- local attempt=0
846
- local session_id=""
847
-
848
- while [ "$attempt" -lt "$wait_seconds" ]; do
849
- session_id=$(get_session_target_id "$before_json" "$started_at_ms" "$WORKING_DIR" "$PROJECT_SCOPE_DIR" "$allow_existing_fallback" || true)
850
- if [ -z "$session_id" ]; then
851
- session_id=$(get_scoped_artifact_session_id_since "$timestamp_file" "$WORKING_DIR" "$PROJECT_SCOPE_DIR" || true)
852
- fi
853
- if [ -n "$session_id" ]; then
854
- printf '%s\n' "$session_id"
855
- return 0
856
- fi
857
- sleep 1
858
- attempt=$((attempt + 1))
859
- done
860
-
861
- return 1
862
- }
863
-
864
- wait_for_session_target_id() {
865
- local before_json="$1"
866
- local started_at_ms="$2"
867
- local wait_seconds="${3:-5}"
868
-
869
- wait_for_scoped_session_id_since "$before_json" "$started_at_ms" "$TIMESTAMP_FILE" "$wait_seconds"
870
- }
871
-
872
- get_fork_cleanup_candidate_id() {
873
- local started_at_ms="$1"
874
- local parent_title="$2"
875
- local workdir="$3"
876
- local project_dir="$4"
877
- local db_path
878
- db_path=$(get_opencode_db_path)
879
-
880
- if [ -z "$started_at_ms" ] || [ -z "$parent_title" ] || [ ! -f "$db_path" ] || ! command -v python3 >/dev/null 2>&1; then
881
- return 1
882
- fi
883
-
884
- python3 - "$db_path" "$started_at_ms" "$parent_title" "$workdir" "$project_dir" <<'PY'
885
- import os
886
- import re
887
- import sqlite3
888
- import sys
889
-
890
- db_path, started_at_ms_raw, parent_title, workdir, project_dir = sys.argv[1:6]
891
- started_at_ms = int(started_at_ms_raw or "0")
892
- scope = {os.path.realpath(path) for path in (workdir, project_dir) if path}
893
- title_pattern = re.compile(rf"^{re.escape(parent_title)} \(fork #\d+\)$")
894
-
895
- try:
896
- conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
897
- rows = conn.execute(
898
- "SELECT id, title, directory, time_created FROM session WHERE time_created >= ? ORDER BY time_created DESC",
899
- (started_at_ms,),
900
- ).fetchall()
901
- except Exception:
902
- raise SystemExit(1)
903
- finally:
904
- try:
905
- conn.close()
906
- except Exception:
907
- pass
908
-
909
- matches = []
910
- for session_id, title, directory, time_created in rows:
911
- if not session_id or not title or not directory or not time_created:
912
- continue
913
- if os.path.realpath(directory) not in scope:
914
- continue
915
- if not title_pattern.match(title):
916
- continue
917
- matches.append(session_id)
918
-
919
- if len(matches) == 1:
920
- print(matches[0])
921
- PY
922
- }
923
-
924
- wait_for_fork_cleanup_candidate_id() {
925
- local started_at_ms="$1"
926
- local parent_title="$2"
927
- local wait_seconds="${3:-5}"
928
- local attempt=0
929
- local session_id=""
930
-
931
- while [ "$attempt" -lt "$wait_seconds" ]; do
932
- session_id=$(get_fork_cleanup_candidate_id "$started_at_ms" "$parent_title" "$WORKING_DIR" "$PROJECT_SCOPE_DIR" || true)
933
- if [ -n "$session_id" ]; then
934
- printf '%s\n' "$session_id"
935
- return 0
936
- fi
937
- sleep 1
938
- attempt=$((attempt + 1))
939
- done
940
-
941
- return 1
942
- }
943
-
944
- file_mtime_secs() {
945
- local file="$1"
946
- if [ ! -f "$file" ]; then
947
- echo 0
948
- return 0
949
- fi
950
-
951
- if stat -c %Y "$file" >/dev/null 2>&1; then
952
- stat -c %Y "$file"
953
- return 0
954
- fi
955
-
956
- stat -f %m "$file"
957
- }
958
-
959
- # Claude-style lock/mtime semantics:
960
- # - lock file CONTENT (PID) = current holder
961
- # - lock file MTIME = last successful consolidation timestamp
962
- read_last_consolidated_at_secs() {
963
- file_mtime_secs "$CONSOLIDATION_LOCK_FILE"
964
- }
965
-
966
- set_file_mtime_secs() {
967
- local file="$1"
968
- local secs="$2"
969
-
970
- if command -v python3 >/dev/null 2>&1; then
971
- python3 - "$file" "$secs" <<'PY'
972
- import os
973
- import sys
974
-
975
- path = sys.argv[1]
976
- secs = int(sys.argv[2])
977
- os.utime(path, (secs, secs))
978
- PY
979
- return 0
980
- fi
981
-
982
- # Best effort fallback; may not be portable across all environments.
983
- touch -d "@$secs" "$file" >/dev/null 2>&1 || true
984
- }
985
-
986
- acquire_simple_lock() {
987
- local lock_file="$1"
988
- local lock_name="$2"
989
-
990
- if [ -f "$lock_file" ]; then
991
- local lock_pid
992
- lock_pid=$(cat "$lock_file" 2>/dev/null || true)
993
- if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then
994
- log "Another $lock_name is already running (PID $lock_pid), skipping"
995
- return 1
996
- fi
997
- rm -f "$lock_file"
998
- fi
999
-
1000
- echo $$ > "$lock_file"
1001
- return 0
1002
- }
1003
-
1004
- release_simple_lock() {
1005
- local lock_file="$1"
1006
- rm -f "$lock_file"
1007
- }
1008
-
1009
- count_sessions_touched_since_ms() {
1010
- local since_ms="$1"
1011
- local exclude_session_id="$2"
1012
-
1013
- if ! command -v jq >/dev/null 2>&1; then
1014
- echo ""
1015
- return 1
1016
- fi
1017
-
1018
- local session_json
1019
- session_json=$(get_session_list_json "$AUTODREAM_SCAN_LIMIT") || {
1020
- echo ""
1021
- return 1
1022
- }
1023
-
1024
- echo "$session_json" | jq -r --argjson since "$since_ms" --arg exclude "$exclude_session_id" '
1025
- [ .[]
1026
- | select((.id // "") != $exclude)
1027
- | select(((.time.updated // .time.created // 0) | tonumber) > $since)
1028
- ] | length
1029
- '
1030
- }
1031
-
1032
- CONSOLIDATION_PRIOR_MTIME=0
1033
-
1034
- try_acquire_consolidation_lock() {
1035
- local path="$CONSOLIDATION_LOCK_FILE"
1036
- local now mtime holder age
1037
-
1038
- # Returns prior mtime via CONSOLIDATION_PRIOR_MTIME so callers can rollback
1039
- # on failure (preserving last successful consolidation time semantics).
1040
-
1041
- now=$(date +%s)
1042
- mtime=0
1043
- holder=""
1044
-
1045
- if [ -f "$path" ]; then
1046
- mtime=$(file_mtime_secs "$path")
1047
- holder=$(cat "$path" 2>/dev/null || true)
1048
- age=$((now - mtime))
1049
-
1050
- if [ "$age" -lt "$AUTODREAM_STALE_LOCK_SECS" ] && [ -n "$holder" ] && kill -0 "$holder" 2>/dev/null; then
1051
- log "Auto-dream lock held by live PID $holder (${age}s old), skipping"
1052
- return 1
1053
- fi
1054
- fi
1055
-
1056
- mkdir -p "$(dirname "$path")"
1057
- echo $$ > "$path"
1058
-
1059
- local verify
1060
- verify=$(cat "$path" 2>/dev/null || true)
1061
- if [ "$verify" != "$$" ]; then
1062
- return 1
1063
- fi
1064
-
1065
- CONSOLIDATION_PRIOR_MTIME="$mtime"
1066
- return 0
1067
- }
1068
-
1069
- rollback_consolidation_lock() {
1070
- local prior_mtime="$1"
1071
- local path="$CONSOLIDATION_LOCK_FILE"
1072
-
1073
- if [ "$prior_mtime" -eq 0 ]; then
1074
- rm -f "$path"
1075
- return 0
1076
- fi
1077
-
1078
- : > "$path"
1079
- set_file_mtime_secs "$path" "$prior_mtime"
1080
- }
1081
-
1082
- cleanup_forked_sessions() {
1083
- local started_at_ms="$1"
1084
- local parent_title="$2"
1085
-
1086
- if [ -z "$started_at_ms" ] || [ -z "$parent_title" ]; then
1087
- return 0
1088
- fi
1089
-
1090
- if ! command -v python3 >/dev/null 2>&1; then
1091
- return 0
1092
- fi
1093
-
1094
- if ! python3 - <<'PY' >/dev/null 2>&1
1095
- pass
1096
- PY
1097
- then
1098
- return 0
1099
- fi
1100
-
1101
- local fork_id
1102
- fork_id=$(wait_for_fork_cleanup_candidate_id "$started_at_ms" "$parent_title" "$SESSION_WAIT_SECONDS" || true)
1103
-
1104
- [ -n "$fork_id" ] || return 0
1105
-
1106
- if "$REAL_OPENCODE" session delete "$fork_id" >/dev/null 2>&1; then
1107
- log "Cleaned up forked session $fork_id"
1108
- fi
1109
- }
1110
-
1111
- session_has_conversation() {
1112
- local session_id="$1"
1113
- local transcript_file
1114
- transcript_file="$(get_transcripts_dir)/${session_id}.jsonl"
1115
-
1116
- if [ -f "$transcript_file" ]; then
1117
- local line_count
1118
- line_count=$(wc -l < "$transcript_file")
1119
- # Empty sessions have at most 1 line (the initial user entry with no content).
1120
- # Real sessions have 3+ lines (user + tool_use + tool_result at minimum).
1121
- [ "$line_count" -gt 1 ]
1122
- return $?
1123
- fi
1124
-
1125
- # Transcript not found — assume content exists to avoid skipping a valid session.
1126
- return 0
1127
- }
1128
-
1129
- transcript_fingerprint() {
1130
- local transcript_file="$1"
1131
- local stat_output
1132
-
1133
- if [ ! -f "$transcript_file" ]; then
1134
- return 1
1135
- fi
1136
-
1137
- if command -v python3 >/dev/null 2>&1; then
1138
- python3 - "$transcript_file" <<'PY'
1139
- import os
1140
- import sys
1141
-
1142
- path = sys.argv[1]
1143
-
1144
- try:
1145
- file_stat = os.stat(path)
1146
- except OSError:
1147
- raise SystemExit(1)
1148
-
1149
- mtime = getattr(file_stat, "st_mtime_ns", int(file_stat.st_mtime * 1_000_000_000))
1150
- print(f"{file_stat.st_size}\t{mtime}")
1151
- PY
1152
- return $?
1153
- fi
1154
-
1155
- if stat_output=$(stat -f '%z %m' "$transcript_file" 2>/dev/null); then
1156
- printf '%s\n' "$stat_output"
1157
- return 0
1158
- fi
1159
-
1160
- if stat_output=$(stat -c '%s %Y' "$transcript_file" 2>/dev/null); then
1161
- printf '%s\n' "$stat_output"
1162
- return 0
1163
- fi
1164
-
1165
- return 1
1166
- }
1167
-
1168
- write_transcript_checkpoint() {
1169
- local output_file="$1"
1170
- local transcripts_dir
1171
- transcripts_dir="$(get_transcripts_dir)"
1172
-
1173
- : > "$output_file"
1174
- [ -d "$transcripts_dir" ] || return 0
1175
-
1176
- if command -v python3 >/dev/null 2>&1; then
1177
- python3 - "$transcripts_dir" > "$output_file" <<'PY'
1178
- import os
1179
- import stat
1180
- import sys
1181
-
1182
- transcripts_dir = sys.argv[1]
1183
-
1184
- try:
1185
- filenames = os.listdir(transcripts_dir)
1186
- except OSError:
1187
- raise SystemExit(0)
1188
-
1189
- for filename in filenames:
1190
- if not filename.endswith(".jsonl"):
1191
- continue
1192
- path = os.path.join(transcripts_dir, filename)
1193
- try:
1194
- file_stat = os.stat(path)
1195
- except OSError:
1196
- continue
1197
- if not stat.S_ISREG(file_stat.st_mode):
1198
- continue
1199
- session_id = filename[:-6]
1200
- if not session_id:
1201
- continue
1202
- mtime = getattr(file_stat, "st_mtime_ns", int(file_stat.st_mtime * 1_000_000_000))
1203
- print(f"{session_id}\t{file_stat.st_size}\t{mtime}")
1204
- PY
1205
- return 0
1206
- fi
1207
-
1208
- find "$transcripts_dir" -maxdepth 1 -type f -name '*.jsonl' -print 2>/dev/null | while IFS= read -r transcript_file; do
1209
- local filename session_id fingerprint
1210
- filename="$(basename "$transcript_file")"
1211
- session_id="${filename%.jsonl}"
1212
- fingerprint="$(transcript_fingerprint "$transcript_file" || true)"
1213
- [ -n "$session_id" ] && [ -n "$fingerprint" ] || continue
1214
- printf '%s\t%s\n' "$session_id" "$fingerprint"
1215
- done > "$output_file"
1216
- }
1217
-
1218
- read_transcript_checkpoint() {
1219
- local session_id="$1"
1220
-
1221
- [ -f "$TRANSCRIPT_CHECKPOINT_FILE" ] || return 1
1222
- awk -F '\t' -v id="$session_id" '$1 == id { print $2 "\t" $3; found=1; exit } END { exit found ? 0 : 1 }' "$TRANSCRIPT_CHECKPOINT_FILE"
1223
- }
1224
-
1225
- session_has_incremental_activity() {
1226
- local session_id="$1"
1227
- local transcript_file previous current
1228
- transcript_file="$(get_transcripts_dir)/${session_id}.jsonl"
1229
-
1230
- if [ -f "$transcript_file" ]; then
1231
- current="$(transcript_fingerprint "$transcript_file" || true)"
1232
- previous="$(read_transcript_checkpoint "$session_id" || true)"
1233
- if [ -z "$previous" ]; then
1234
- return 0
1235
- fi
1236
- [ "$current" != "$previous" ]
1237
- return $?
1238
- fi
1239
-
1240
- # If no transcript is available, keep the existing conservative behavior:
1241
- # session_diff/session-list discovery may still have found a valid new turn.
1242
- return 0
1243
- }
1244
-
1245
- run_extraction_if_needed() {
1246
- local session_id="$1"
1247
- local memory_written_during_session="$2"
1248
-
1249
- if [ "$EXTRACT_ENABLED" = "0" ]; then
1250
- return 0
1251
- fi
1252
-
1253
- if [ "$memory_written_during_session" = "1" ]; then
1254
- log "Main agent already wrote memories during session, skipping extraction"
1255
- return 0
1256
- fi
1257
-
1258
- if ! acquire_simple_lock "$EXTRACT_LOCK_FILE" "memory extraction"; then
1259
- return 0
1260
- fi
1261
-
1262
- log "Extracting memories from session $session_id..."
1263
- log "Extraction log: $EXTRACT_LOG_FILE"
1264
-
1265
- local parent_session_title
1266
- parent_session_title=$(get_session_title_from_db "$session_id" || true)
1267
-
1268
- local cmd=("$REAL_OPENCODE" run -s "$session_id" --fork --dir "$WORKING_DIR")
1269
- if [ -n "$EXTRACT_MODEL" ]; then
1270
- cmd+=(-m "$EXTRACT_MODEL")
1271
- fi
1272
- if [ -n "$EXTRACT_AGENT" ]; then
1273
- cmd+=(--agent "$EXTRACT_AGENT")
1274
- fi
1275
- cmd+=("$EXTRACT_PROMPT")
1276
-
1277
- local fork_started_at_ms
1278
- fork_started_at_ms=$(( $(date +%s) * 1000 ))
1279
-
1280
- if "${cmd[@]}" >> "$EXTRACT_LOG_FILE" 2>&1; then
1281
- log "Memory extraction completed successfully"
1282
- else
1283
- local code=$?
1284
- log "Memory extraction failed (exit code $code). Check $EXTRACT_LOG_FILE for details"
1285
- fi
1286
-
1287
- cleanup_forked_sessions "$fork_started_at_ms" "$parent_session_title"
1288
- release_simple_lock "$EXTRACT_LOCK_FILE"
1289
- }
1290
-
1291
- run_autodream_if_needed() {
1292
- local session_id="$1"
1293
-
1294
- if [ "$AUTODREAM_ENABLED" = "0" ]; then
1295
- return 0
1296
- fi
1297
-
1298
- if ! command -v jq >/dev/null 2>&1; then
1299
- log "jq not found, skipping auto-dream (requires JSON session parsing)"
1300
- return 0
1301
- fi
1302
-
1303
- local last_at now hours_since since_ms touched_count
1304
- last_at=$(read_last_consolidated_at_secs)
1305
- now=$(date +%s)
1306
-
1307
- hours_since=$(((now - last_at) / 3600))
1308
- if [ "$hours_since" -lt "$AUTODREAM_MIN_HOURS" ]; then
1309
- return 0
1310
- fi
1311
-
1312
- since_ms=$((last_at * 1000))
1313
- touched_count=$(count_sessions_touched_since_ms "$since_ms" "$session_id" || true)
1314
- if [ -z "$touched_count" ]; then
1315
- log "Unable to inspect touched sessions, skipping auto-dream"
1316
- return 0
1317
- fi
1318
-
1319
- if [ "$touched_count" -lt "$AUTODREAM_MIN_SESSIONS" ]; then
1320
- return 0
1321
- fi
1322
-
1323
- if ! try_acquire_consolidation_lock; then
1324
- return 0
1325
- fi
1326
-
1327
- log "Auto-dream firing (${hours_since}h since last consolidation, ${touched_count} sessions touched)"
1328
- log "Auto-dream log: $AUTODREAM_LOG_FILE"
1329
-
1330
- local parent_session_title
1331
- parent_session_title=$(get_session_title_from_db "$session_id" || true)
1332
-
1333
- local cmd=("$REAL_OPENCODE" run -s "$session_id" --fork --dir "$WORKING_DIR")
1334
- if [ -n "$AUTODREAM_MODEL" ]; then
1335
- cmd+=(-m "$AUTODREAM_MODEL")
1336
- fi
1337
- if [ -n "$AUTODREAM_AGENT" ]; then
1338
- cmd+=(--agent "$AUTODREAM_AGENT")
1339
- fi
1340
- cmd+=("$AUTODREAM_PROMPT")
1341
-
1342
- local fork_started_at_ms
1343
- fork_started_at_ms=$(( $(date +%s) * 1000 ))
1344
-
1345
- if "${cmd[@]}" >> "$AUTODREAM_LOG_FILE" 2>&1; then
1346
- log "Auto-dream consolidation completed successfully"
1347
- # Keep lock mtime at "now" to represent last consolidated timestamp.
1348
- else
1349
- local code=$?
1350
- log "Auto-dream consolidation failed (exit code $code). Rolling back gate timestamp"
1351
- rollback_consolidation_lock "$CONSOLIDATION_PRIOR_MTIME"
1352
- fi
1353
-
1354
- cleanup_forked_sessions "$fork_started_at_ms" "$parent_session_title"
1355
- }
1356
-
1357
- run_post_session_tasks() {
1358
- local session_id="$1"
1359
- local memory_written_during_session="$2"
1360
-
1361
- run_extraction_if_needed "$session_id" "$memory_written_during_session"
1362
- run_autodream_if_needed "$session_id"
1363
- }
1364
-
1365
- run_post_session_for_invocation() {
1366
- local session_id
1367
- local memory_written_during_session
1368
-
1369
- # Capture the session ID for this invocation. In background mode this wait
1370
- # must not delay the visible exit of the wrapped opencode process.
1371
- session_id=$(wait_for_session_target_id "$PRE_SESSION_JSON" "$SESSION_CAPTURE_STARTED_AT_MS" "$SESSION_WAIT_SECONDS" || true)
1372
- if [ -z "$session_id" ]; then
1373
- log "No session found, skipping post-session memory maintenance"
1374
- cleanup_timestamp
1375
- return 0
1376
- fi
1377
-
1378
- # Skip if session had no real conversation (e.g. user opened TUI and exited).
1379
- if ! session_has_conversation "$session_id"; then
1380
- log "Session $session_id has no conversation, skipping post-session memory maintenance"
1381
- cleanup_timestamp
1382
- return 0
1383
- fi
1384
-
1385
- if ! session_has_incremental_activity "$session_id"; then
1386
- log "Session $session_id has no new transcript activity, skipping post-session memory maintenance"
1387
- cleanup_timestamp
1388
- return 0
1389
- fi
1390
-
1391
- memory_written_during_session=0
1392
- if has_new_memories; then
1393
- memory_written_during_session=1
1394
- fi
1395
-
1396
- # Timestamp file is no longer needed after the check above.
1397
- cleanup_timestamp
1398
-
1399
- run_post_session_tasks "$session_id" "$memory_written_during_session"
1400
- }
1401
-
1402
- # ============================================================================
1403
- # Main
1404
- # ============================================================================
1405
-
1406
- # Step 0: Create timestamp marker before running opencode
1407
- TIMESTAMP_FILE=$(mktemp)
1408
- TRANSCRIPT_CHECKPOINT_FILE=$(mktemp)
1409
- write_transcript_checkpoint "$TRANSCRIPT_CHECKPOINT_FILE"
1410
- SESSION_CAPTURE_STARTED_AT_MS=$(( $(date +%s) * 1000 ))
1411
- PRE_SESSION_JSON=$(get_session_list_json "$AUTODREAM_SCAN_LIMIT" 2>/dev/null || true)
1412
-
1413
- # Step 1: Run the real opencode with all original arguments, capture exit code
1414
- opencode_exit=0
1415
- if main_prompt_requests_ignore_memory "$@"; then
1416
- OPENCODE_MEMORY_IGNORE=1 "$REAL_OPENCODE" "$@" || opencode_exit=$?
1417
- else
1418
- "$REAL_OPENCODE" "$@" || opencode_exit=$?
1419
- fi
1420
-
1421
- # Step 2: If no maintenance task is enabled, exit early
1422
- if [ "$EXTRACT_ENABLED" = "0" ] && [ "$AUTODREAM_ENABLED" = "0" ]; then
1423
- cleanup_timestamp
1424
- exit $opencode_exit
1425
- fi
1426
-
1427
- # Step 3: Run post-session maintenance (foreground for debug, background by default).
1428
- if [ "$FOREGROUND" = "1" ]; then
1429
- run_post_session_for_invocation
1430
- else
1431
- run_post_session_for_invocation &
1432
- disown
1433
- log "Post-session memory maintenance started in background (PID $!)"
1434
- fi
1435
-
1436
- exit $opencode_exit