opencode-claude-memory 1.5.2 โ†’ 1.6.1

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**.
@@ -215,6 +224,18 @@ 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
+ # Build published artifacts
234
+ bun run build
235
+
236
+ # Release: push to main triggers semantic-release โ†’ npm publish
237
+ ```
238
+
218
239
  ## ๐Ÿ“„ License
219
240
 
220
241
  [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,8 +266,9 @@ 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
273
  TMP_BASE_DIR="${TMPDIR:-/tmp}"
208
274
  while [ "$TMP_BASE_DIR" != "/" ] && [ "${TMP_BASE_DIR%/}" != "$TMP_BASE_DIR" ]; do
@@ -407,6 +473,351 @@ get_latest_session_id() {
407
473
  fi
408
474
  }
409
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
+
410
821
  file_mtime_secs() {
411
822
  local file="$1"
412
823
  if [ ! -f "$file" ]; then
@@ -565,7 +976,7 @@ run_extraction_if_needed() {
565
976
  log "Extracting memories from session $session_id..."
566
977
  log "Extraction log: $EXTRACT_LOG_FILE"
567
978
 
568
- local cmd=("$REAL_OPENCODE" run -s "$session_id" --fork)
979
+ local cmd=("$REAL_OPENCODE" run -s "$session_id" --fork --dir "$WORKING_DIR")
569
980
  if [ -n "$EXTRACT_MODEL" ]; then
570
981
  cmd+=(-m "$EXTRACT_MODEL")
571
982
  fi
@@ -623,7 +1034,7 @@ run_autodream_if_needed() {
623
1034
  log "Auto-dream firing (${hours_since}h since last consolidation, ${touched_count} sessions touched)"
624
1035
  log "Auto-dream log: $AUTODREAM_LOG_FILE"
625
1036
 
626
- local cmd=("$REAL_OPENCODE" run -s "$session_id" --fork)
1037
+ local cmd=("$REAL_OPENCODE" run -s "$session_id" --fork --dir "$WORKING_DIR")
627
1038
  if [ -n "$AUTODREAM_MODEL" ]; then
628
1039
  cmd+=(-m "$AUTODREAM_MODEL")
629
1040
  fi
@@ -657,10 +1068,16 @@ run_post_session_tasks() {
657
1068
 
658
1069
  # Step 0: Create timestamp marker before running opencode
659
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)
660
1073
 
661
1074
  # Step 1: Run the real opencode with all original arguments, capture exit code
662
1075
  opencode_exit=0
663
- "$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
664
1081
 
665
1082
  # Step 2: If no maintenance task is enabled, exit early
666
1083
  if [ "$EXTRACT_ENABLED" = "0" ] && [ "$AUTODREAM_ENABLED" = "0" ]; then
@@ -668,8 +1085,8 @@ if [ "$EXTRACT_ENABLED" = "0" ] && [ "$AUTODREAM_ENABLED" = "0" ]; then
668
1085
  exit $opencode_exit
669
1086
  fi
670
1087
 
671
- # Step 3: Get the most recent session ID
672
- 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)
673
1090
  if [ -z "$session_id" ]; then
674
1091
  log "No session found, skipping post-session memory maintenance"
675
1092
  cleanup_timestamp
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ export declare const MemoryPlugin: Plugin;