opencode-claude-memory 1.5.1 โ†’ 1.6.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/README.md CHANGED
@@ -122,6 +122,15 @@ The shell hook defines an `opencode()` function that delegates to `opencode-memo
122
122
 
123
123
  The implementation ports core logic from Claude Code for path hashing, git-root/worktree handling, memory format, and memory prompting behavior, so both tools can operate on the same files safely.
124
124
 
125
+ Key modules ported from Claude Code's `src/memdir/`:
126
+
127
+ | Module | Source | Purpose |
128
+ |---|---|---|
129
+ | `memoryScan.ts` | `memoryScan.ts` | Recursive directory scan + frontmatter header parsing |
130
+ | `recall.ts` | `findRelevantMemories.ts` | Memory recall via keyword scoring (heuristic, no LLM side-query) |
131
+ | `prompt.ts` | `memoryTypes.ts` + `memdir.ts` | System prompt sections, type taxonomy, truncation |
132
+ | `memory.ts` | `memdir.ts` | `truncateEntrypoint()` aligned with `truncateEntrypointContent()` |
133
+
125
134
  ## ๐Ÿ‘ฅ Who this is for
126
135
 
127
136
  - You use **both Claude Code and OpenCode**.
@@ -174,7 +183,7 @@ Yes. Set `OPENCODE_MEMORY_AUTODREAM=0`. You can also tune gates with:
174
183
 
175
184
  ### Logs
176
185
 
177
- Logs are written to `/tmp/opencode-claude-memory/<project-hash>/`:
186
+ Logs are written to `$TMPDIR/opencode-memory-logs/`:
178
187
  - `extract-*.log`: automatic memory extraction
179
188
  - `dream-*.log`: auto-dream consolidation
180
189
 
@@ -215,6 +224,16 @@ Supported memory types:
215
224
  - `memory_search`: search by keyword
216
225
  - `memory_read`: read full memory content
217
226
 
227
+ ## ๐Ÿงช Development
228
+
229
+ ```bash
230
+ # Run tests
231
+ bun test
232
+
233
+ # No build needed โ€” raw TS consumed by OpenCode
234
+ # Release: push to main triggers semantic-release โ†’ npm publish
235
+ ```
236
+
218
237
  ## ๐Ÿ“„ License
219
238
 
220
239
  [MIT](LICENSE) ยฉ [kuitos](https://github.com/kuitos)
@@ -40,16 +40,52 @@
40
40
 
41
41
  set -euo pipefail
42
42
 
43
- SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
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)"
44
59
  PACKAGE_JSON="$SCRIPT_DIR/../package.json"
45
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
+
46
79
  print_wrapper_version() {
47
80
  local version
81
+ local package_json
82
+
83
+ package_json="$(resolve_package_json)"
48
84
 
49
- version=$(awk -F'"' '/^[[:space:]]*"version"[[:space:]]*:/ { print $4; exit }' "$PACKAGE_JSON")
85
+ version=$(awk -F'"' '/^[[:space:]]*"version"[[:space:]]*:/ { print $4; exit }' "$package_json")
50
86
 
51
87
  if [ -z "$version" ]; then
52
- echo "[opencode-memory] ERROR: Cannot read package version from $PACKAGE_JSON" >&2
88
+ echo "[opencode-memory] ERROR: Cannot read package version from $package_json" >&2
53
89
  exit 1
54
90
  fi
55
91
 
@@ -184,6 +220,35 @@ find_real_opencode() {
184
220
 
185
221
  REAL_OPENCODE="$(find_real_opencode)"
186
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
+
187
252
  # ============================================================================
188
253
  # Configuration
189
254
  # ============================================================================
@@ -201,10 +266,17 @@ AUTODREAM_SCAN_LIMIT="${OPENCODE_MEMORY_AUTODREAM_SCAN_LIMIT:-200}"
201
266
  AUTODREAM_MODEL="${OPENCODE_MEMORY_AUTODREAM_MODEL:-$EXTRACT_MODEL}"
202
267
  AUTODREAM_AGENT="${OPENCODE_MEMORY_AUTODREAM_AGENT:-$EXTRACT_AGENT}"
203
268
  AUTODREAM_STALE_LOCK_SECS=$((60 * 60))
269
+ SESSION_WAIT_SECONDS="${OPENCODE_MEMORY_SESSION_WAIT_SECONDS:-5}"
204
270
 
205
- WORKING_DIR="${OPENCODE_MEMORY_DIR:-$(pwd)}"
271
+ WORKING_DIR="${OPENCODE_MEMORY_DIR:-$(extract_working_dir_from_args "$@" || pwd)}"
206
272
 
207
- LOG_BASE_DIR="/tmp/opencode-claude-memory"
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
208
280
 
209
281
  # Scope lock files at project root granularity (not per-subdirectory).
210
282
  PROJECT_SCOPE_DIR="$WORKING_DIR"
@@ -215,7 +287,7 @@ fi
215
287
  PROJECT_KEY="$(printf '%s' "$PROJECT_SCOPE_DIR" | cksum | awk '{print $1}')"
216
288
 
217
289
  # Lock files (prevent concurrent work on the same project)
218
- LOCK_DIR="/tmp/opencode-memory-locks"
290
+ LOCK_DIR="$TMP_BASE_DIR/opencode-memory-locks"
219
291
  mkdir -p "$LOCK_DIR"
220
292
  EXTRACT_LOCK_FILE="$LOCK_DIR/${PROJECT_KEY}.extract.lock"
221
293
 
@@ -224,7 +296,7 @@ mkdir -p "$STATE_DIR"
224
296
  CONSOLIDATION_LOCK_FILE="$STATE_DIR/${PROJECT_KEY}.consolidate-lock"
225
297
 
226
298
  # Logs
227
- LOG_DIR="$LOG_BASE_DIR/${PROJECT_KEY}"
299
+ LOG_DIR="$TMP_BASE_DIR/opencode-memory-logs"
228
300
  mkdir -p "$LOG_DIR"
229
301
  TASK_LOG_PREFIX="$(date +%Y%m%d-%H%M%S)-${PROJECT_KEY}"
230
302
  EXTRACT_LOG_FILE="$LOG_DIR/extract-${TASK_LOG_PREFIX}.log"
@@ -401,6 +473,351 @@ get_latest_session_id() {
401
473
  fi
402
474
  }
403
475
 
476
+ get_session_target_id() {
477
+ local before_json="$1"
478
+ local started_at_ms="$2"
479
+ local workdir="$3"
480
+ local project_dir="$4"
481
+ local after_json
482
+
483
+ after_json=$(get_session_list_json "$AUTODREAM_SCAN_LIMIT") || return 1
484
+
485
+ if command -v python3 >/dev/null 2>&1; then
486
+ python3 - "$before_json" "$after_json" "$started_at_ms" "$workdir" "$project_dir" <<'PY'
487
+ import json
488
+ import os
489
+ import sys
490
+
491
+ before_raw, after_raw, started_at_ms_raw, workdir, project_dir = sys.argv[1:6]
492
+
493
+ def parse(raw):
494
+ try:
495
+ data = json.loads(raw)
496
+ return data if isinstance(data, list) else []
497
+ except Exception:
498
+ return []
499
+
500
+ def timestamp(item):
501
+ time_obj = item.get("time") if isinstance(item.get("time"), dict) else {}
502
+ for key in ("updated", "created"):
503
+ value = item.get(key)
504
+ if value is not None:
505
+ try:
506
+ return int(value)
507
+ except Exception:
508
+ pass
509
+ value = time_obj.get(key)
510
+ if value is not None:
511
+ try:
512
+ return int(value)
513
+ except Exception:
514
+ pass
515
+ return 0
516
+
517
+ def normalize(path):
518
+ if not path:
519
+ return ""
520
+ return os.path.realpath(path)
521
+
522
+ before = parse(before_raw)
523
+ after = parse(after_raw)
524
+ started_at_ms = int(started_at_ms_raw or "0")
525
+ before_ids = {item.get("id") for item in before if item.get("id")}
526
+ workdir = normalize(workdir)
527
+ project_dir = normalize(project_dir)
528
+
529
+ def in_scope(item):
530
+ directory = normalize(item.get("directory", ""))
531
+ return directory != "" and directory in {workdir, project_dir}
532
+
533
+ def choose(candidates):
534
+ ranked = sorted(
535
+ [item for item in candidates if item.get("id")],
536
+ key=lambda item: timestamp(item),
537
+ reverse=True,
538
+ )
539
+ if ranked:
540
+ print(ranked[0]["id"])
541
+ return True
542
+ return False
543
+
544
+ new_sessions = [item for item in after if item.get("id") not in before_ids]
545
+ updated_sessions = [item for item in after if timestamp(item) > started_at_ms]
546
+
547
+ for pool in (
548
+ [item for item in new_sessions if in_scope(item)],
549
+ [item for item in updated_sessions if in_scope(item)],
550
+ [item for item in after if in_scope(item)],
551
+ ):
552
+ if choose(pool):
553
+ break
554
+ PY
555
+ return 0
556
+ fi
557
+
558
+ get_latest_session_id
559
+ }
560
+
561
+ get_transcripts_dir() {
562
+ printf '%s\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/transcripts"
563
+ }
564
+
565
+ get_storage_session_diff_dir() {
566
+ printf '%s\n' "$HOME/.local/share/opencode/storage/session_diff"
567
+ }
568
+
569
+ resolve_session_directory() {
570
+ local session_id="$1"
571
+ local output_file
572
+
573
+ output_file=$(mktemp)
574
+ if ! "$REAL_OPENCODE" export "$session_id" >"$output_file" 2>/dev/null; then
575
+ rm -f "$output_file"
576
+ return 1
577
+ fi
578
+
579
+ if [ ! -s "$output_file" ]; then
580
+ rm -f "$output_file"
581
+ return 1
582
+ fi
583
+
584
+ if command -v python3 >/dev/null 2>&1; then
585
+ python3 - "$output_file" <<'PY'
586
+ import json
587
+ from pathlib import Path
588
+ import sys
589
+
590
+ raw = Path(sys.argv[1]).read_text()
591
+ start = raw.find('{')
592
+ if start == -1:
593
+ raise SystemExit(1)
594
+
595
+ try:
596
+ data = json.loads(raw[start:])
597
+ except Exception:
598
+ raise SystemExit(1)
599
+
600
+ directory = ((data.get("info") or {}).get("directory") or "")
601
+ if directory:
602
+ print(directory)
603
+ PY
604
+ local status=$?
605
+ rm -f "$output_file"
606
+ return $status
607
+ fi
608
+
609
+ rm -f "$output_file"
610
+ return 1
611
+ }
612
+
613
+ get_scoped_artifact_session_id_since() {
614
+ local timestamp_file="$1"
615
+ local workdir="$2"
616
+ local project_dir="$3"
617
+
618
+ if ! command -v python3 >/dev/null 2>&1; then
619
+ return 1
620
+ fi
621
+
622
+ local candidates
623
+ candidates=$(python3 - "$timestamp_file" "$HOME" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" <<'PY'
624
+ import os
625
+ import sys
626
+
627
+ timestamp_file, home_dir, claude_dir = sys.argv[1:4]
628
+
629
+ try:
630
+ threshold = os.path.getmtime(timestamp_file)
631
+ except OSError:
632
+ raise SystemExit(1)
633
+
634
+ sources = [
635
+ (os.path.join(home_dir, '.local', 'share', 'opencode', 'storage', 'session_diff'), '.json'),
636
+ (os.path.join(claude_dir, 'transcripts'), '.jsonl'),
637
+ ]
638
+
639
+ latest = {}
640
+ for directory, suffix in sources:
641
+ if not os.path.isdir(directory):
642
+ continue
643
+ for entry in os.scandir(directory):
644
+ if not entry.is_file() or not entry.name.endswith(suffix):
645
+ continue
646
+ try:
647
+ mtime = entry.stat().st_mtime
648
+ except OSError:
649
+ continue
650
+ if mtime <= threshold:
651
+ continue
652
+ session_id = entry.name[:-len(suffix)]
653
+ latest[session_id] = max(latest.get(session_id, -1), mtime)
654
+
655
+ for session_id, mtime in sorted(latest.items(), key=lambda item: item[1], reverse=True):
656
+ print(session_id)
657
+ PY
658
+ ) || return 1
659
+
660
+ local session_id=""
661
+ local session_dir=""
662
+ local normalized_session_dir=""
663
+ local normalized_workdir=""
664
+ local normalized_project_dir=""
665
+ normalized_workdir=$(python3 - "$workdir" <<'PY'
666
+ import os, sys
667
+ print(os.path.realpath(sys.argv[1]))
668
+ PY
669
+ )
670
+ normalized_project_dir=$(python3 - "$project_dir" <<'PY'
671
+ import os, sys
672
+ print(os.path.realpath(sys.argv[1]))
673
+ PY
674
+ )
675
+
676
+ while IFS= read -r session_id; do
677
+ [ -n "$session_id" ] || continue
678
+ session_dir=$(resolve_session_directory "$session_id" || true)
679
+ [ -n "$session_dir" ] || continue
680
+ normalized_session_dir=$(python3 - "$session_dir" <<'PY'
681
+ import os, sys
682
+ print(os.path.realpath(sys.argv[1]))
683
+ PY
684
+ )
685
+ if [ "$normalized_session_dir" = "$normalized_workdir" ] || [ "$normalized_session_dir" = "$normalized_project_dir" ]; then
686
+ printf '%s\n' "$session_id"
687
+ return 0
688
+ fi
689
+ done <<EOF
690
+ $candidates
691
+ EOF
692
+
693
+ return 1
694
+ }
695
+
696
+ get_latest_storage_session_id_since() {
697
+ local timestamp_file="$1"
698
+ local storage_dir
699
+ storage_dir=$(get_storage_session_diff_dir)
700
+
701
+ if [ ! -d "$storage_dir" ]; then
702
+ return 1
703
+ fi
704
+
705
+ if command -v python3 >/dev/null 2>&1; then
706
+ python3 - "$storage_dir" "$timestamp_file" <<'PY'
707
+ import os
708
+ import sys
709
+
710
+ storage_dir, timestamp_file = sys.argv[1:3]
711
+
712
+ try:
713
+ threshold = os.path.getmtime(timestamp_file)
714
+ except OSError:
715
+ raise SystemExit(1)
716
+
717
+ latest = None
718
+ latest_mtime = -1.0
719
+
720
+ for entry in os.scandir(storage_dir):
721
+ if not entry.is_file() or not entry.name.endswith('.json'):
722
+ continue
723
+ try:
724
+ mtime = entry.stat().st_mtime
725
+ except OSError:
726
+ continue
727
+ if mtime <= threshold:
728
+ continue
729
+ if mtime > latest_mtime:
730
+ latest_mtime = mtime
731
+ latest = entry.name[:-5]
732
+
733
+ if latest:
734
+ print(latest)
735
+ PY
736
+ return 0
737
+ fi
738
+
739
+ return 1
740
+ }
741
+
742
+ get_latest_transcript_session_id_since() {
743
+ local timestamp_file="$1"
744
+ local transcripts_dir
745
+ transcripts_dir=$(get_transcripts_dir)
746
+
747
+ if [ ! -d "$transcripts_dir" ]; then
748
+ return 1
749
+ fi
750
+
751
+ if command -v python3 >/dev/null 2>&1; then
752
+ python3 - "$transcripts_dir" "$timestamp_file" <<'PY'
753
+ import os
754
+ import sys
755
+
756
+ transcripts_dir, timestamp_file = sys.argv[1:3]
757
+
758
+ try:
759
+ threshold = os.path.getmtime(timestamp_file)
760
+ except OSError:
761
+ raise SystemExit(1)
762
+
763
+ latest = None
764
+ latest_mtime = -1.0
765
+
766
+ for entry in os.scandir(transcripts_dir):
767
+ if not entry.is_file() or not entry.name.endswith('.jsonl'):
768
+ continue
769
+ try:
770
+ mtime = entry.stat().st_mtime
771
+ except OSError:
772
+ continue
773
+ if mtime <= threshold:
774
+ continue
775
+ if mtime > latest_mtime:
776
+ latest_mtime = mtime
777
+ latest = entry.name[:-6]
778
+
779
+ if latest:
780
+ print(latest)
781
+ PY
782
+ return 0
783
+ fi
784
+
785
+ return 1
786
+ }
787
+
788
+ main_prompt_requests_ignore_memory() {
789
+ if [ "${1:-}" != "run" ]; then
790
+ return 1
791
+ fi
792
+
793
+ local joined
794
+ joined=$(printf '%s\n' "$*" | tr '[:upper:]' '[:lower:]')
795
+ 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"
796
+ }
797
+
798
+ wait_for_session_target_id() {
799
+ local before_json="$1"
800
+ local started_at_ms="$2"
801
+ local wait_seconds="${3:-5}"
802
+ local attempt=0
803
+ local session_id=""
804
+
805
+ while [ "$attempt" -lt "$wait_seconds" ]; do
806
+ session_id=$(get_session_target_id "$before_json" "$started_at_ms" "$WORKING_DIR" "$PROJECT_SCOPE_DIR" || true)
807
+ if [ -z "$session_id" ]; then
808
+ session_id=$(get_scoped_artifact_session_id_since "$TIMESTAMP_FILE" "$WORKING_DIR" "$PROJECT_SCOPE_DIR" || true)
809
+ fi
810
+ if [ -n "$session_id" ]; then
811
+ printf '%s\n' "$session_id"
812
+ return 0
813
+ fi
814
+ sleep 1
815
+ attempt=$((attempt + 1))
816
+ done
817
+
818
+ return 1
819
+ }
820
+
404
821
  file_mtime_secs() {
405
822
  local file="$1"
406
823
  if [ ! -f "$file" ]; then
@@ -559,7 +976,7 @@ run_extraction_if_needed() {
559
976
  log "Extracting memories from session $session_id..."
560
977
  log "Extraction log: $EXTRACT_LOG_FILE"
561
978
 
562
- local cmd=("$REAL_OPENCODE" run -s "$session_id" --fork)
979
+ local cmd=("$REAL_OPENCODE" run -s "$session_id" --fork --dir "$WORKING_DIR")
563
980
  if [ -n "$EXTRACT_MODEL" ]; then
564
981
  cmd+=(-m "$EXTRACT_MODEL")
565
982
  fi
@@ -617,7 +1034,7 @@ run_autodream_if_needed() {
617
1034
  log "Auto-dream firing (${hours_since}h since last consolidation, ${touched_count} sessions touched)"
618
1035
  log "Auto-dream log: $AUTODREAM_LOG_FILE"
619
1036
 
620
- local cmd=("$REAL_OPENCODE" run -s "$session_id" --fork)
1037
+ local cmd=("$REAL_OPENCODE" run -s "$session_id" --fork --dir "$WORKING_DIR")
621
1038
  if [ -n "$AUTODREAM_MODEL" ]; then
622
1039
  cmd+=(-m "$AUTODREAM_MODEL")
623
1040
  fi
@@ -651,10 +1068,16 @@ run_post_session_tasks() {
651
1068
 
652
1069
  # Step 0: Create timestamp marker before running opencode
653
1070
  TIMESTAMP_FILE=$(mktemp)
1071
+ SESSION_CAPTURE_STARTED_AT_MS=$(( $(date +%s) * 1000 ))
1072
+ PRE_SESSION_JSON=$(get_session_list_json "$AUTODREAM_SCAN_LIMIT" 2>/dev/null || true)
654
1073
 
655
1074
  # Step 1: Run the real opencode with all original arguments, capture exit code
656
1075
  opencode_exit=0
657
- "$REAL_OPENCODE" "$@" || opencode_exit=$?
1076
+ if main_prompt_requests_ignore_memory "$@"; then
1077
+ OPENCODE_MEMORY_IGNORE=1 "$REAL_OPENCODE" "$@" || opencode_exit=$?
1078
+ else
1079
+ "$REAL_OPENCODE" "$@" || opencode_exit=$?
1080
+ fi
658
1081
 
659
1082
  # Step 2: If no maintenance task is enabled, exit early
660
1083
  if [ "$EXTRACT_ENABLED" = "0" ] && [ "$AUTODREAM_ENABLED" = "0" ]; then
@@ -662,8 +1085,8 @@ if [ "$EXTRACT_ENABLED" = "0" ] && [ "$AUTODREAM_ENABLED" = "0" ]; then
662
1085
  exit $opencode_exit
663
1086
  fi
664
1087
 
665
- # Step 3: Get the most recent session ID
666
- session_id=$(get_latest_session_id || true)
1088
+ # Step 3: Capture the session ID for this invocation
1089
+ session_id=$(wait_for_session_target_id "$PRE_SESSION_JSON" "$SESSION_CAPTURE_STARTED_AT_MS" "$SESSION_WAIT_SECONDS" || true)
667
1090
  if [ -z "$session_id" ]; then
668
1091
  log "No session found, skipping post-session memory maintenance"
669
1092
  cleanup_timestamp
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-memory",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "Claude Code-compatible memory compatibility layer for OpenCode โ€” zero config, local-first, no migration",
6
6
  "main": "src/index.ts",