@dosx/agent-memory 0.0.13

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 (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/bin/agent-memory.js +496 -0
  4. package/hooks/README.md +292 -0
  5. package/hooks/agent-memory-hooks/agent-memory-common.sh +1020 -0
  6. package/hooks/agent-memory-hooks/agent-memory-session.sh +98 -0
  7. package/hooks/agent-memory-hooks/agent-memory-sync.sh +147 -0
  8. package/hooks/claude-code/settings.json +51 -0
  9. package/hooks/codex/config.toml.snippet +38 -0
  10. package/hooks/codex/hooks.json +51 -0
  11. package/hooks/copilot/agent-memory.json +34 -0
  12. package/hooks/cursor/hooks.json +36 -0
  13. package/hooks/gemini/settings.json +50 -0
  14. package/hooks/git/pre-commit +45 -0
  15. package/hooks/install-hooks.sh +358 -0
  16. package/hooks/opencode/agent-memory.ts +320 -0
  17. package/package.json +22 -0
  18. package/skills/agent-memory/SKILL.md +185 -0
  19. package/skills/agent-memory/references/agent-block.md +115 -0
  20. package/skills/agent-memory/references/bootstrap.md +84 -0
  21. package/skills/agent-memory/references/init.md +211 -0
  22. package/skills/agent-memory/references/install-hooks.md +113 -0
  23. package/skills/agent-memory/references/lint.md +113 -0
  24. package/skills/agent-memory/references/sync.md +120 -0
  25. package/skills/agent-memory/references/update.md +145 -0
  26. package/skills/agent-memory/vendor/README.md +132 -0
  27. package/skills/agent-memory/vendor/UPDATE.md +369 -0
  28. package/skills/agent-memory/vendor/memory/active-work/TEMPLATE.md +33 -0
  29. package/skills/agent-memory/vendor/memory/current.md +34 -0
  30. package/skills/agent-memory/vendor/memory/decisions.md +30 -0
  31. package/skills/agent-memory/vendor/memory/index.md +55 -0
  32. package/skills/agent-memory/vendor/memory/instructions.md +340 -0
  33. package/skills/agent-memory/vendor/memory/log.md +38 -0
@@ -0,0 +1,1020 @@
1
+ # agent-memory shared helpers — source from session/sync hooks only.
2
+ # Deterministic, evidence-backed updates only (git + harness session ID).
3
+ #
4
+ # Hook-owned memory fields (the agent owns everything else — task meaning,
5
+ # semantic log bullets, decisions.md, Done/Next, index.md — see hooks/README.md
6
+ # "Safe write scope" and instructions.md):
7
+ # active-work/<branch>.md → Touched files (session-cumulative git + stdin paths)
8
+ # log.md → per-session heading (sessionStart), file-path bullets
9
+ # (full checkpoints only — not postToolUse/afterFileEdit)
10
+ # current.md → In progress list (sessionStart, from active-work/)
11
+ #
12
+ # Expects after agent_memory_init_context: cwd, memory, state_file globals.
13
+
14
+ # Filled by parse_hook_stdin (optional).
15
+ hook_stdin_session_id=""
16
+ hook_stdin_cwd=""
17
+
18
+ # Set to 1 by reset_logged_files_if_session_changed when sessionStart continues
19
+ # the same no-id session (logged_files_session was already __no_id__).
20
+ agent_memory_no_id_continuing=0
21
+
22
+ # Read harness stdin without blocking forever when fd 0 is open but idle (CLI).
23
+ read_hook_stdin() {
24
+ local line rest=""
25
+ [ -t 0 ] && return 0
26
+ if IFS= read -r -t 1 line; then
27
+ rest="$line"
28
+ while IFS= read -r -t 1 line; do
29
+ rest+="$line"$'\n'
30
+ [ ${#rest} -gt 1048576 ] && break
31
+ done
32
+ fi
33
+ printf '%s' "$rest"
34
+ }
35
+
36
+ # Extract a quoted string value for a JSON field from a harness payload.
37
+ # Returns first match (head -1). Field name must be regex-safe (alnum/_).
38
+ json_string_field() {
39
+ printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1
40
+ }
41
+
42
+ # jq is preferred for JSON parsing (spec-correct: handles escapes, nesting,
43
+ # unicode). Probe once per process; fall back to sed regex when jq is absent so
44
+ # the hooks stay zero-dependency and portable.
45
+ if [ -z "${_AMC_HAVE_JQ:-}" ]; then
46
+ if command -v jq >/dev/null 2>&1; then _AMC_HAVE_JQ=1; else _AMC_HAVE_JQ=0; fi
47
+ fi
48
+
49
+ parse_hook_stdin() {
50
+ local input="${1:-}"
51
+ hook_stdin_session_id=""
52
+ hook_stdin_cwd=""
53
+ hook_stdin_tool_name=""
54
+ hook_stdin_tool_file=""
55
+ [ -n "$input" ] || return 0
56
+ if [ "$_AMC_HAVE_JQ" -eq 1 ]; then
57
+ local parsed rest
58
+ parsed=$(printf '%s' "$input" | jq -r '
59
+ [ (.session_id // .conversation_id // .sessionId // ""),
60
+ (.cwd // (.workspace_roots[0] // "")),
61
+ (.tool_name // ""),
62
+ (.tool_input.file_path // .tool_input.path // .file_path // "")
63
+ ] | @tsv')
64
+ hook_stdin_session_id=${parsed%%$'\t'*}
65
+ rest=${parsed#*$'\t'}; hook_stdin_cwd=${rest%%$'\t'*}
66
+ rest=${rest#*$'\t'}; hook_stdin_tool_name=${rest%%$'\t'*}
67
+ hook_stdin_tool_file=${rest#*$'\t'}
68
+ else
69
+ hook_stdin_session_id=$(json_string_field "$input" session_id)
70
+ [ -z "$hook_stdin_session_id" ] && hook_stdin_session_id=$(json_string_field "$input" conversation_id)
71
+ [ -z "$hook_stdin_session_id" ] && hook_stdin_session_id=$(json_string_field "$input" sessionId)
72
+ hook_stdin_cwd=$(json_string_field "$input" cwd)
73
+ if [ -z "$hook_stdin_cwd" ]; then
74
+ hook_stdin_cwd=$(printf '%s' "$input" | sed -n \
75
+ 's/.*"workspace_roots"[[:space:]]*:\[[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
76
+ fi
77
+ hook_stdin_tool_name=$(json_string_field "$input" tool_name)
78
+ hook_stdin_tool_file=$(json_string_field "$input" file_path)
79
+ if [ -z "$hook_stdin_tool_file" ]; then
80
+ hook_stdin_tool_file=$(printf '%s' "$input" | sed -n \
81
+ 's/.*"tool_input"[[:space:]]*:[[:space:]]*{[^}]*"path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
82
+ | head -1)
83
+ fi
84
+ fi
85
+ }
86
+
87
+ # Resolve to an absolute path (realpath when available).
88
+ agent_memory_resolve_realpath() {
89
+ local p=$1
90
+ if command -v realpath >/dev/null 2>&1; then
91
+ realpath "$p" 2>/dev/null || return 1
92
+ elif command -v python3 >/dev/null 2>&1; then
93
+ python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$p" 2>/dev/null || return 1
94
+ else
95
+ printf '%s\n' "$(cd "$(dirname "$p")" 2>/dev/null && pwd)/$(basename "$p")"
96
+ fi
97
+ }
98
+
99
+ # True when child is root or a path under root.
100
+ agent_memory_path_under_root() {
101
+ local child=$1 root=$2
102
+ case "$child" in
103
+ "$root" | "$root"/*) return 0 ;;
104
+ *) return 1 ;;
105
+ esac
106
+ }
107
+
108
+ # Refuse if path is a symlink (memory trust boundary).
109
+ agent_memory_refuse_symlink_file() {
110
+ local path=$1
111
+ if [ -L "$path" ]; then
112
+ printf 'agent-memory: refused symlink memory path: %s\n' "$path" >&2
113
+ return 1
114
+ fi
115
+ return 0
116
+ }
117
+
118
+ # Walk memory root → path; refuse if any existing component is a symlink.
119
+ agent_memory_refuse_symlink_parents_under_memory() {
120
+ local dest=$1 cur rest part mem="$memory"
121
+ case "$dest" in
122
+ "$mem" | "$mem"/*) ;;
123
+ *) return 0 ;;
124
+ esac
125
+ agent_memory_refuse_symlink_file "$dest" || return 1
126
+ cur="$mem"
127
+ rest="${dest#"$mem"/}"
128
+ [ "$rest" = "$dest" ] && [ "$dest" != "$mem" ] && return 0
129
+ while [ -n "$rest" ]; do
130
+ if [ "${rest#*/}" != "$rest" ]; then
131
+ part="${rest%%/*}"
132
+ rest="${rest#*/}"
133
+ else
134
+ part="$rest"
135
+ rest=""
136
+ fi
137
+ cur="$cur/$part"
138
+ if { [ -e "$cur" ] || [ -L "$cur" ]; } && [ -L "$cur" ]; then
139
+ printf 'agent-memory: refused symlink in memory path: %s\n' "$cur" >&2
140
+ return 1
141
+ fi
142
+ done
143
+ return 0
144
+ }
145
+
146
+ # Guard before read/write of a path under $memory.
147
+ agent_memory_guard_memory_path() {
148
+ agent_memory_refuse_symlink_parents_under_memory "$1"
149
+ }
150
+
151
+ # Shared entry point for sync.sh and session.sh: read harness stdin, parse it,
152
+ # and resolve cwd/memory/state_file globals. Call once after sourcing common.sh.
153
+ agent_memory_init_context() {
154
+ local hook_input="" cwd_real mem_real
155
+ if [ ! -t 0 ]; then
156
+ hook_input=$(read_hook_stdin)
157
+ fi
158
+ parse_hook_stdin "$hook_input"
159
+ cwd=$(resolve_project_dir "$hook_stdin_cwd")
160
+ memory="$cwd/.agents/memory"
161
+ if [ -d "$memory" ] || [ -L "$memory" ]; then
162
+ cwd_real=$(agent_memory_resolve_realpath "$cwd") || return 1
163
+ mem_real=$(agent_memory_resolve_realpath "$memory") || return 1
164
+ if ! agent_memory_path_under_root "$mem_real" "$cwd_real"; then
165
+ printf 'agent-memory: memory path escapes project: %s\n' "$memory" >&2
166
+ return 1
167
+ fi
168
+ fi
169
+ state_file="$memory/.hook-sync-state"
170
+ }
171
+
172
+ # Harness project roots — see hooks/README.md (Cursor, Claude, Codex, Gemini, git, OpenCode).
173
+ resolve_project_dir() {
174
+ local stdin_cwd="${1:-}"
175
+ if [ -n "${AGENT_MEMORY_PROJECT_DIR:-}" ]; then printf '%s' "$AGENT_MEMORY_PROJECT_DIR"; return; fi
176
+ if [ -n "${CURSOR_PROJECT_DIR:-}" ]; then printf '%s' "$CURSOR_PROJECT_DIR"; return; fi
177
+ if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then printf '%s' "$CLAUDE_PROJECT_DIR"; return; fi
178
+ if [ -n "${CODEX_PROJECT_DIR:-}" ]; then printf '%s' "$CODEX_PROJECT_DIR"; return; fi
179
+ if [ -n "${GITHUB_WORKSPACE:-}" ]; then printf '%s' "$GITHUB_WORKSPACE"; return; fi
180
+ if [ -n "${GEMINI_PROJECT_DIR:-}" ]; then printf '%s' "$GEMINI_PROJECT_DIR"; return; fi
181
+ if [ -n "$stdin_cwd" ]; then printf '%s' "$stdin_cwd"; return; fi
182
+ printf '%s' "${PWD:-.}"
183
+ }
184
+
185
+ # Canonical: AGENT_MEMORY_SESSION_ID (from sessionStart env), then stdin JSON,
186
+ # then .hook-sync-state (prior sessionStart; sync only). CURSOR_SESSION_ID is
187
+ # not set by Cursor natively (Cursor sends session_id via stdin JSON); kept as
188
+ # an interop fallback for third-party hooks that export it via sessionStart env.
189
+ # GEMINI_SESSION_ID is provided by Gemini CLI.
190
+ # Second arg: allow_state_fallback (1=sync default, 0=sessionStart — no stale ID).
191
+ resolve_session_id() {
192
+ local stdin_sid="${1:-}"
193
+ local allow_state_fallback="${2:-1}"
194
+ if [ -n "${AGENT_MEMORY_SESSION_ID:-}" ]; then printf '%s' "$AGENT_MEMORY_SESSION_ID"; return; fi
195
+ if [ -n "${CURSOR_SESSION_ID:-}" ]; then printf '%s' "$CURSOR_SESSION_ID"; return; fi
196
+ if [ -n "${GEMINI_SESSION_ID:-}" ]; then printf '%s' "$GEMINI_SESSION_ID"; return; fi
197
+ if [ -n "$stdin_sid" ]; then printf '%s' "$stdin_sid"; return; fi
198
+ if [ "$allow_state_fallback" = "1" ]; then
199
+ read_state current_session_id ""
200
+ else
201
+ printf ''
202
+ fi
203
+ }
204
+
205
+ persist_session_id() {
206
+ local sid="${1:-}"
207
+ [ -n "$sid" ] || return 0
208
+ write_state current_session_id "$sid"
209
+ }
210
+
211
+ # __no_id__: no session ID bound; same no-id session keeps dedupe.
212
+ NO_ID_SESSION_SENTINEL="__no_id__"
213
+
214
+ # Clear per-session checkpoint path sets (log dedupe + active-work accumulation).
215
+ _clear_session_path_state() {
216
+ write_state logged_files ""
217
+ write_state session_touched_files ""
218
+ write_state log_summary_mode ""
219
+ }
220
+
221
+ # Keep logged_files (per-session dedupe set of already-bulleted paths) bound to
222
+ # the active session id (or __no_id__). Wipe it when the bound session changes,
223
+ # so a new session re-bullets paths. id_upgrade_from preserves the prior bound
224
+ # across a no-id→id promotion so a session that gains a real id mid-flight keeps
225
+ # its dedupe set (see promote_session_log_heading). Sets agent_memory_no_id_continuing
226
+ # so ensure_session_log_heading can reuse an existing same-day no-id heading.
227
+ # Also resets session_touched_files and log_summary_mode on session change.
228
+ reset_logged_files_if_session_changed() {
229
+ local sid=$1 context="${2:-sync}"
230
+ local last
231
+ agent_memory_no_id_continuing=0
232
+ sid=$(normalize_session_id_for_checkpoint "$sid")
233
+ last=$(read_state logged_files_session "")
234
+ if [ -n "$sid" ]; then
235
+ [ "$sid" = "$last" ] && return 0
236
+ if [ -z "$last" ] || [ "$last" = "$NO_ID_SESSION_SENTINEL" ]; then
237
+ write_state logged_files_session "$sid"
238
+ return 0
239
+ fi
240
+ if [ "$context" = "sync" ]; then
241
+ pending=$(read_state id_upgrade_from "")
242
+ if [ -n "$pending" ] && [ "$pending" = "$last" ] && [ "$sid" != "$last" ]; then
243
+ write_state logged_files_session "$sid"
244
+ write_state id_upgrade_from ""
245
+ return 0
246
+ fi
247
+ fi
248
+ _clear_session_path_state
249
+ write_state logged_files_session "$sid"
250
+ return 0
251
+ fi
252
+ if [ "$context" = "sessionStart" ]; then
253
+ if [ "$last" = "$NO_ID_SESSION_SENTINEL" ]; then
254
+ agent_memory_no_id_continuing=1
255
+ return 0
256
+ fi
257
+ agent_memory_no_id_continuing=0
258
+ _clear_session_path_state
259
+ write_state logged_files_session "$NO_ID_SESSION_SENTINEL"
260
+ return 0
261
+ fi
262
+ [ "$last" = "$NO_ID_SESSION_SENTINEL" ] && return 0
263
+ if [ -z "$last" ]; then
264
+ write_state logged_files_session "$NO_ID_SESSION_SENTINEL"
265
+ return 0
266
+ fi
267
+ _clear_session_path_state
268
+ write_state logged_files_session "$NO_ID_SESSION_SENTINEL"
269
+ }
270
+
271
+ # Resolve current branch from git and cache it in .hook-sync-state. Called at
272
+ # full checkpoints (sessionStart/afterAgentResponse/preCompact) so postToolUse
273
+ # can read the cached branch without spawning git. Caveat: a mid-session
274
+ # `git checkout` makes the cache stale until the next full checkpoint refreshes
275
+ # it; low impact (a stray bullet in the old branch's active-work, reconciled at
276
+ # afterAgentResponse).
277
+ refresh_branch_cache() {
278
+ [ -n "${cwd:-}" ] || return 0
279
+ local b last
280
+ b=$(git -C "$cwd" branch --show-current 2>/dev/null || true)
281
+ [ -n "$b" ] || return 0
282
+ last=$(read_state branch "")
283
+ write_state branch "$b"
284
+ if [ -n "$last" ] && [ "$last" != "$b" ]; then
285
+ _clear_session_path_state
286
+ fi
287
+ }
288
+
289
+ sanitize_branch() {
290
+ local b="${1:-}"
291
+ if [ -z "$b" ]; then
292
+ b=$(read_state branch "")
293
+ [ -z "$b" ] && b=$(git -C "$cwd" branch --show-current 2>/dev/null || true)
294
+ fi
295
+ printf '%s' "$b" | tr -c 'A-Za-z0-9._-' '-'
296
+ }
297
+
298
+ read_state() {
299
+ local key=$1 default=$2
300
+ if [ -L "${state_file:-}" ]; then
301
+ printf 'agent-memory: read_state refused symlink state file: %s\n' "$state_file" >&2
302
+ printf '%s' "$default"
303
+ return 1
304
+ fi
305
+ [ -f "$state_file" ] || { printf '%s' "$default"; return; }
306
+ awk -v k="$key" '
307
+ {
308
+ pos = index($0, "=")
309
+ if (pos > 0 && substr($0, 1, pos - 1) == k) {
310
+ print substr($0, pos + 1)
311
+ found = 1
312
+ exit
313
+ }
314
+ }
315
+ END { if (!found) print "" }
316
+ ' "$state_file"
317
+ }
318
+
319
+ write_state() {
320
+ local key=$1 val=$2
321
+ local tmp cur
322
+ # Reject controls that would break key=value lines or forge extra keys.
323
+ case "$key" in
324
+ '' | *[!A-Za-z0-9_]* | *$'\n'* | *$'\r'*)
325
+ printf 'agent-memory: write_state refused invalid key: %s\n' "$key" >&2
326
+ return 1
327
+ ;;
328
+ esac
329
+ # Values may use RS (\x1e) as a multi-path delimiter (session_touched_files,
330
+ # logged_files). Individual paths must not contain \x1e — see normalize_repo_rel_path.
331
+ case "$val" in
332
+ *$'\n'* | *$'\r'*)
333
+ printf 'agent-memory: write_state refused newline in %s\n' "$key" >&2
334
+ return 1
335
+ ;;
336
+ esac
337
+ if [ -L "${state_file:-}" ]; then
338
+ printf 'agent-memory: write_state refused symlink state file: %s\n' "$state_file" >&2
339
+ return 1
340
+ fi
341
+ cur=$(read_state "$key" "")
342
+ if [ -f "$state_file" ] && [ "$cur" = "$val" ]; then
343
+ return 0
344
+ fi
345
+ tmp=$(mktemp)
346
+ if [ -f "$state_file" ]; then
347
+ while IFS= read -r line || [ -n "$line" ]; do
348
+ [ "${line%%=*}" = "$key" ] && continue
349
+ printf '%s\n' "$line"
350
+ done <"$state_file" >"$tmp"
351
+ else
352
+ : >"$tmp"
353
+ fi
354
+ printf '%s=%s\n' "$key" "$val" >>"$tmp"
355
+ mv "$tmp" "$state_file"
356
+ }
357
+
358
+ # When 1, list_non_memory_changes also includes files from the tip commit since
359
+ # last_processed_head (afterAgentResponse / preCompact only). Set by sync hook.
360
+ agent_memory_include_commit_files="${agent_memory_include_commit_files:-0}"
361
+
362
+ list_worktree_changes() {
363
+ {
364
+ git -C "$cwd" diff --name-only 2>/dev/null || true
365
+ git -C "$cwd" diff --cached --name-only 2>/dev/null || true
366
+ git -C "$cwd" ls-files --others --exclude-standard 2>/dev/null || true
367
+ } | sort -u | grep -vE '^\.agents/memory/|^$' || true
368
+ }
369
+
370
+ list_non_memory_changes() {
371
+ {
372
+ list_worktree_changes
373
+ if [ "$agent_memory_include_commit_files" = "1" ]; then
374
+ local current_head last_head
375
+ current_head=$(git -C "$cwd" rev-parse HEAD 2>/dev/null || true)
376
+ last_head=$(read_state last_processed_head "")
377
+ if [ -n "$current_head" ] && [ "$current_head" != "$last_head" ]; then
378
+ git -C "$cwd" show --pretty=format: --name-only "$current_head" 2>/dev/null || true
379
+ fi
380
+ fi
381
+ } | sort -u | grep -vE '^\.agents/memory/|^$' || true
382
+ }
383
+
384
+ branch_to_task_stub() {
385
+ local branch=$1
386
+ local prefix rest
387
+ case "$branch" in
388
+ feat-*|feature-*)
389
+ prefix="Feature"
390
+ rest=${branch#feat-}
391
+ rest=${rest#feature-}
392
+ ;;
393
+ fix-*|bugfix-*)
394
+ prefix="Fix"
395
+ rest=${branch#fix-}
396
+ rest=${rest#bugfix-}
397
+ ;;
398
+ chore-*)
399
+ prefix="Chore"
400
+ rest=${branch#chore-}
401
+ ;;
402
+ refactor-*)
403
+ prefix="Refactor"
404
+ rest=${branch#refactor-}
405
+ ;;
406
+ docs-*)
407
+ prefix="Docs"
408
+ rest=${branch#docs-}
409
+ ;;
410
+ test-*)
411
+ prefix="Test"
412
+ rest=${branch#test-}
413
+ ;;
414
+ *)
415
+ printf '%s' "$branch"
416
+ return
417
+ ;;
418
+ esac
419
+ rest=${rest//-/ }
420
+ printf '%s: %s' "$prefix" "$rest"
421
+ }
422
+
423
+ ensure_active_work() {
424
+ local branch aw real
425
+ # Prefer branch cache (refreshed at full checkpoints) so postToolUse /
426
+ # afterFileEdit skip an extra git spawn on every edit.
427
+ real=$(read_state branch "")
428
+ if [ -z "$real" ]; then
429
+ real=$(git -C "$cwd" branch --show-current 2>/dev/null || echo "local")
430
+ fi
431
+ [ -n "$real" ] || real="local"
432
+ branch=$(sanitize_branch "$real")
433
+ [ -n "$branch" ] || branch="local"
434
+ aw="$memory/active-work/${branch}.md"
435
+ agent_memory_guard_memory_path "$aw" || return 1
436
+ if [ ! -f "$aw" ]; then
437
+ if [ -f "$memory/active-work/TEMPLATE.md" ]; then
438
+ agent_memory_guard_memory_path "$memory/active-work/TEMPLATE.md" || return 1
439
+ local content
440
+ content=$(cat "$memory/active-work/TEMPLATE.md")
441
+ printf '%s\n' "${content//<branch>/$real}" >"$aw"
442
+ else
443
+ cat >"$aw" <<EOF
444
+ # Active Work — Branch: \`${real}\`
445
+
446
+ ## Task
447
+
448
+ - _No active task._
449
+
450
+ ## Progress
451
+
452
+ - _none_
453
+
454
+ ## Touched files
455
+
456
+ - _none_
457
+
458
+ ## Blockers
459
+
460
+ - _none_
461
+
462
+ ## Notes
463
+
464
+ - _none_
465
+ EOF
466
+ fi
467
+ fi
468
+ printf '%s' "$aw"
469
+ }
470
+
471
+ # Replace the bullets of a `## <header>` markdown section with the non-empty
472
+ # lines from list_file (printed verbatim as bullets). Empty list → "- _none_".
473
+ # Used by update_touched_files and refresh_current_in_progress.
474
+ replace_section_bullets() {
475
+ local file=$1 header=$2 list_file=$3
476
+ agent_memory_guard_memory_path "$file" || return 1
477
+ awk -v list="$list_file" -v hdr="$header" '
478
+ BEGIN {
479
+ while ((getline line < list) > 0) if (line != "") arr[++n] = line
480
+ close(list)
481
+ }
482
+ $0 ~ hdr { in_section = 1; print; next }
483
+ in_section && /^## / { if (!done) emit(); in_section = 0 }
484
+ in_section && /^- / { if (!done) { emit(); done = 1 } next }
485
+ { print }
486
+ END { if (in_section && !done) emit() }
487
+ function emit() {
488
+ if (n == 0) print "- _none_"
489
+ else for (i = 1; i <= n; i++) print arr[i]
490
+ }
491
+ ' "$file" >"${file}.tmp" && mv "${file}.tmp" "$file"
492
+ }
493
+
494
+ # Merge repo-relative paths into session_touched_files (RS-delimited, like logged_files).
495
+ merge_paths_into_session_touched() {
496
+ local list_tmp=$1 accumulated
497
+ [ -s "$list_tmp" ] || return 0
498
+ accumulated=$(read_state session_touched_files "")
499
+ while IFS= read -r f || [ -n "$f" ]; do
500
+ [ -n "$f" ] || continue
501
+ file_already_logged "$f" "$accumulated" && continue
502
+ if [ -z "$accumulated" ]; then accumulated="$f"
503
+ else accumulated="$accumulated"$'\x1e'"$f"; fi
504
+ done <"$list_tmp"
505
+ write_state session_touched_files "$accumulated"
506
+ }
507
+
508
+ # Sorted unique paths accumulated for the current session (stdout, one per line).
509
+ read_session_touched_paths_sorted() {
510
+ local accumulated
511
+ accumulated=$(read_state session_touched_files "")
512
+ [ -n "$accumulated" ] || return 0
513
+ printf '%s\n' "$accumulated" | tr $'\x1e' '\n' | sort -u | grep -v '^$' || true
514
+ }
515
+
516
+ # Write the session-cumulative touched list to active-work (replaces section bullets).
517
+ write_active_work_touched_from_session() {
518
+ local aw=$1 bullets_tmp paths_tmp
519
+ paths_tmp=$(mktemp)
520
+ read_session_touched_paths_sorted >"$paths_tmp"
521
+ if [ ! -s "$paths_tmp" ]; then
522
+ rm -f "$paths_tmp"
523
+ return 0
524
+ fi
525
+ bullets_tmp=$(mktemp)
526
+ while IFS= read -r f || [ -n "$f" ]; do
527
+ [ -n "$f" ] || continue
528
+ printf '%s\n' "- \`$f\`" >>"$bullets_tmp"
529
+ done <"$paths_tmp"
530
+ replace_section_bullets "$aw" '^## Touched files' "$bullets_tmp"
531
+ rm -f "$paths_tmp" "$bullets_tmp"
532
+ }
533
+
534
+ update_touched_files() {
535
+ local aw=$1 list_tmp=$2
536
+ [ -s "$list_tmp" ] || return 0
537
+ merge_paths_into_session_touched "$list_tmp"
538
+ write_active_work_touched_from_session "$aw"
539
+ }
540
+
541
+ # Merge one path into session state and refresh active-work. Used by postToolUse /
542
+ # afterFileEdit (git-free). Does not write log.md bullets — full checkpoints only.
543
+ add_touched_file() {
544
+ local aw=$1 rel=$2 single accumulated
545
+ [ -n "$rel" ] || return 0
546
+ accumulated=$(read_state session_touched_files "")
547
+ file_already_logged "$rel" "$accumulated" && return 0
548
+ single=$(mktemp)
549
+ printf '%s\n' "$rel" >"$single"
550
+ merge_paths_into_session_touched "$single"
551
+ rm -f "$single"
552
+ write_active_work_touched_from_session "$aw"
553
+ }
554
+
555
+ update_task_stub() {
556
+ local aw=$1 branch stub
557
+ agent_memory_guard_memory_path "$aw" || return 1
558
+ branch=$(sanitize_branch)
559
+ [ -n "$branch" ] || branch="local"
560
+ stub=$(branch_to_task_stub "$branch")
561
+
562
+ awk -v stub="$stub" '
563
+ /^## Task/ { in_task = 1; print; next }
564
+ in_task && /^## / {
565
+ if (!replaced && !has_real) print "- " stub " _(refine in session)_"
566
+ in_task = 0
567
+ }
568
+ in_task && /^- / {
569
+ if ($0 ~ /_No active task\./ || $0 ~ /_none_/) {
570
+ print "- " stub " _(refine in session)_"
571
+ replaced = 1
572
+ } else {
573
+ print
574
+ has_real = 1
575
+ }
576
+ next
577
+ }
578
+ { print }
579
+ END {
580
+ if (in_task && !replaced && !has_real) print "- " stub " _(refine in session)_"
581
+ }
582
+ ' "$aw" >"${aw}.tmp" && mv "${aw}.tmp" "$aw"
583
+ }
584
+
585
+ today_date() {
586
+ date +%Y-%m-%d
587
+ }
588
+
589
+ # OpenCode rotates ses_* IDs across idle/compaction events within one work day.
590
+ # One log heading per calendar day; later ses_* IDs map to the bound heading id.
591
+ opencode_refresh_log_day() {
592
+ [ "${AGENT_MEMORY_HOST:-}" = "opencode" ] || return 0
593
+ local today last
594
+ today=$(today_date)
595
+ last=$(read_state opencode_log_date "")
596
+ [ "$today" = "$last" ] && return 0
597
+ write_state opencode_log_date "$today"
598
+ write_state opencode_log_heading_id ""
599
+ }
600
+
601
+ # First ## [YYYY-MM-DD] [ses_*] heading on stdout, or empty.
602
+ find_opencode_log_heading_same_day() {
603
+ local log="$memory/log.md" date
604
+ date=$(today_date)
605
+ [ -f "$log" ] || return 0
606
+ agent_memory_guard_memory_path "$log" || return 1
607
+ grep -E "^## \\[${date}\\] \\[ses_" "$log" 2>/dev/null \
608
+ | head -1 \
609
+ | sed -n 's/^## \[[0-9-]*\] \[\(ses_[^]]*\)\].*/\1/p'
610
+ }
611
+
612
+ # Map a raw harness session id to the log heading id (OpenCode coalescence).
613
+ # Read-only — does not bind state or create headings (see ensure_log_heading_for_checkpoint).
614
+ normalize_session_id_for_checkpoint() {
615
+ local sid="${1:-}"
616
+ if [ "${AGENT_MEMORY_HOST:-}" = "opencode" ] && [ -n "$sid" ]; then
617
+ opencode_refresh_log_day
618
+ local bound existing
619
+ bound=$(read_state opencode_log_heading_id "")
620
+ if [ -n "$bound" ] && session_heading_exists "$bound"; then
621
+ printf '%s' "$bound"
622
+ return
623
+ fi
624
+ existing=$(find_opencode_log_heading_same_day)
625
+ if [ -n "$existing" ]; then
626
+ printf '%s' "$existing"
627
+ return
628
+ fi
629
+ fi
630
+ printf '%s' "$sid"
631
+ }
632
+
633
+ _opencode_bind_log_heading() {
634
+ local id="${1:-}"
635
+ [ "${AGENT_MEMORY_HOST:-}" = "opencode" ] || return 0
636
+ [ -n "$id" ] || return 0
637
+ case "$id" in
638
+ ses_*)
639
+ write_state opencode_log_heading_id "$id"
640
+ write_state opencode_log_date "$(today_date)"
641
+ ;;
642
+ esac
643
+ }
644
+
645
+ # Create or resolve the log heading before a checkpoint append. Prints the heading id.
646
+ ensure_log_heading_for_checkpoint() {
647
+ local sid="${1:-}" resolved log="$memory/log.md" line today
648
+ [ -n "$sid" ] || return 0
649
+ agent_memory_guard_memory_path "$log" || return 1
650
+ opencode_refresh_log_day
651
+ resolved=$(normalize_session_id_for_checkpoint "$sid")
652
+ if session_heading_exists "$resolved"; then
653
+ _opencode_bind_log_heading "$resolved"
654
+ if [ "${AGENT_MEMORY_HOST:-}" = "opencode" ]; then
655
+ prune_empty_opencode_session_headings "$resolved"
656
+ fi
657
+ printf '%s' "$resolved"
658
+ return 0
659
+ fi
660
+ if [ "${AGENT_MEMORY_HOST:-}" = "opencode" ]; then
661
+ case "$resolved" in
662
+ ses_*)
663
+ today=$(today_date)
664
+ local existing
665
+ existing=$(find_opencode_log_heading_same_day)
666
+ if [ -n "$existing" ]; then
667
+ _opencode_bind_log_heading "$existing"
668
+ prune_empty_opencode_session_headings "$existing"
669
+ printf '%s' "$existing"
670
+ return 0
671
+ fi
672
+ ;;
673
+ esac
674
+ fi
675
+ promote_session_log_heading "$resolved"
676
+ line=$(session_log_heading_line "$resolved")
677
+ strip_log_placeholder
678
+ if [ ! -f "$log" ]; then
679
+ printf '# Log\n\n%s\n' "$line" >"$log"
680
+ else
681
+ printf '\n%s\n' "$line" >>"$log"
682
+ fi
683
+ _opencode_bind_log_heading "$resolved"
684
+ printf '%s' "$resolved"
685
+ }
686
+
687
+ # Drop same-day empty OpenCode ses_* headings except the kept id (no bullets).
688
+ prune_empty_opencode_session_headings() {
689
+ local keep="${1:-}" date log="$memory/log.md"
690
+ [ "${AGENT_MEMORY_HOST:-}" = "opencode" ] || return 0
691
+ [ -n "$keep" ] || return 0
692
+ [ -f "$log" ] || return 0
693
+ agent_memory_guard_memory_path "$log" || return 1
694
+ date=$(today_date)
695
+ awk -v date="$date" -v keep="$keep" '
696
+ function is_ses_heading(line, id) {
697
+ if (line !~ "^## \\[" date "\\] \\[ses_") return 0
698
+ if (match(line, /\[ses_[^]]+\]/)) {
699
+ id = substr(line, RSTART + 1, RLENGTH - 2)
700
+ return id
701
+ }
702
+ return 0
703
+ }
704
+ {
705
+ lines[++n] = $0
706
+ }
707
+ END {
708
+ drop[0] = 0
709
+ for (i = 1; i <= n; i++) {
710
+ id = is_ses_heading(lines[i])
711
+ if (!id) continue
712
+ if (id == keep) continue
713
+ empty = 1
714
+ for (j = i + 1; j <= n; j++) {
715
+ if (lines[j] ~ /^## /) break
716
+ if (lines[j] ~ /^- /) { empty = 0; break }
717
+ }
718
+ if (empty) drop[i] = 1
719
+ }
720
+ for (i = 1; i <= n; i++) if (!drop[i]) print lines[i]
721
+ }
722
+ ' "$log" >"${log}.tmp" && mv "${log}.tmp" "$log"
723
+ }
724
+
725
+ session_log_heading_line() {
726
+ local date sid
727
+ date=$(today_date)
728
+ sid="${1:-}"
729
+ if [ -n "$sid" ]; then
730
+ printf '## [%s] [%s]' "$date" "$sid"
731
+ else
732
+ printf '## [%s]' "$date"
733
+ fi
734
+ }
735
+
736
+ session_heading_exists() {
737
+ local log="$memory/log.md" sid="${1:-}" date
738
+ date=$(today_date)
739
+ [ -f "$log" ] || return 1
740
+ agent_memory_guard_memory_path "$log" || return 1
741
+ if [ -n "$sid" ]; then
742
+ awk -v sid="$sid" '
743
+ function is_sid_heading(line) {
744
+ return line ~ "^## \\[[0-9]{4}-[0-9]{2}-[0-9]{2}\\] \\[" sid "\\]"
745
+ }
746
+ is_sid_heading($0) && $0 !~ /hook checkpoint/ { found = 1 }
747
+ END { exit(found ? 0 : 1) }
748
+ ' "$log"
749
+ else
750
+ awk -v date="$date" '
751
+ function is_no_id_heading(line) {
752
+ if (line !~ "^## \\[" date "\\]") return 0
753
+ if (line ~ /hook checkpoint/) return 0
754
+ if (line ~ "^## \\[" date "\\] \\[[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-") return 0
755
+ if (line ~ "^## \\[" date "\\]$") return 1
756
+ return line ~ "^## \\[" date "\\] \\["
757
+ }
758
+ is_no_id_heading($0) { found = 1 }
759
+ END { exit(found ? 0 : 1) }
760
+ ' "$log"
761
+ fi
762
+ }
763
+
764
+ # Any same-day session heading — for no-id reuse on sessionStart.
765
+ same_day_session_heading_exists_any() {
766
+ local log="$memory/log.md" date
767
+ date=$(today_date)
768
+ [ -f "$log" ] || return 1
769
+ agent_memory_guard_memory_path "$log" || return 1
770
+ awk -v date="$date" '
771
+ function is_session_heading(line) {
772
+ if (line !~ "^## \\[" date "\\]") return 0
773
+ if (line ~ /hook checkpoint/) return 0
774
+ if (line ~ "^## \\[" date "\\]$") return 1
775
+ return line ~ "^## \\[" date "\\] \\["
776
+ }
777
+ is_session_heading($0) { found = 1 }
778
+ END { exit(found ? 0 : 1) }
779
+ ' "$log"
780
+ }
781
+
782
+ session_log_has_target_heading() {
783
+ local sid="${1:-}"
784
+ sid=$(normalize_session_id_for_checkpoint "$sid")
785
+ if [ -n "$sid" ]; then
786
+ session_heading_exists "$sid"
787
+ else
788
+ same_day_session_heading_exists_any
789
+ fi
790
+ }
791
+
792
+ strip_log_placeholder() {
793
+ local log="$memory/log.md"
794
+ [ -f "$log" ] || return 0
795
+ agent_memory_guard_memory_path "$log" || return 1
796
+ awk '
797
+ /^_No entries yet\._$/ { next }
798
+ { print }
799
+ ' "$log" >"${log}.tmp" && mv "${log}.tmp" "$log"
800
+ }
801
+
802
+ promote_session_log_heading() {
803
+ local sid="${1:-}"
804
+ local log="$memory/log.md" date prev_bound
805
+ [ -n "$sid" ] || return 0
806
+ [ -f "$log" ] || return 0
807
+ agent_memory_guard_memory_path "$log" || return 1
808
+ date=$(today_date)
809
+ session_heading_exists "$sid" && return 0
810
+ prev_bound=$(read_state logged_files_session "")
811
+ if [ -n "$prev_bound" ] && [ "$prev_bound" != "$sid" ] \
812
+ && [ "$prev_bound" != "$NO_ID_SESSION_SENTINEL" ]; then
813
+ if awk -v prev="$prev_bound" -v sid="$sid" -v date="$date" '
814
+ $0 ~ "^## \\[" date "\\] \\[" prev "\\]" {
815
+ sub("\\[" prev "\\]", "[" sid "]", $0)
816
+ promoted = 1
817
+ print
818
+ next
819
+ }
820
+ { print }
821
+ END { exit(promoted ? 0 : 1) }
822
+ ' "$log" >"${log}.tmp" && mv "${log}.tmp" "$log"; then
823
+ write_state id_upgrade_from "$prev_bound"
824
+ fi
825
+ session_heading_exists "$sid" && return 0
826
+ fi
827
+ if ! awk -v date="$date" '
828
+ $0 ~ "^## \\[" date "\\]$" && $0 !~ /hook checkpoint/ { found = 1 }
829
+ END { exit(found ? 0 : 1) }
830
+ ' "$log"; then
831
+ return 0
832
+ fi
833
+ awk -v sid="$sid" -v date="$date" '
834
+ $0 ~ "^## \\[" date "\\]$" && !promoted {
835
+ print "## [" date "] [" sid "]"
836
+ promoted = 1
837
+ next
838
+ }
839
+ { print }
840
+ ' "$log" >"${log}.tmp" && mv "${log}.tmp" "$log"
841
+ }
842
+
843
+ # sessionStart only — full checkpoints call ensure_log_heading_for_checkpoint directly.
844
+ ensure_session_log_heading() {
845
+ local sid="${1:-}" context="${2:-}" line log="$memory/log.md"
846
+ [ "$context" = "sessionStart" ] || return 0
847
+ agent_memory_guard_memory_path "$log" || return 1
848
+ if [ -n "$sid" ]; then
849
+ ensure_log_heading_for_checkpoint "$sid" >/dev/null
850
+ return 0
851
+ fi
852
+ if [ "${agent_memory_no_id_continuing:-0}" = "1" ] \
853
+ && same_day_session_heading_exists_any; then
854
+ return 0
855
+ elif session_heading_exists ""; then
856
+ return 0
857
+ fi
858
+ strip_log_placeholder
859
+ line=$(session_log_heading_line "")
860
+ if [ ! -f "$log" ]; then
861
+ printf '# Log\n\n%s\n' "$line" >"$log"
862
+ else
863
+ printf '\n%s\n' "$line" >>"$log"
864
+ fi
865
+ }
866
+
867
+ # Record separator–delimited logged paths for current session (logged_files).
868
+ # logged set ($2) is read once by the caller and passed in to avoid N+1 read_state.
869
+ file_already_logged() {
870
+ local f=$1 logged=$2
871
+ [ -n "$logged" ] || return 1
872
+ case $'\x1e'"${logged}"$'\x1e' in
873
+ *$'\x1e'"$f"$'\x1e'*) return 0 ;;
874
+ *) return 1 ;;
875
+ esac
876
+ }
877
+
878
+ append_log_file_bullets() {
879
+ local sid=$1 list_tmp=$2
880
+ local log="$memory/log.md" count pending_tmp bullets_tmp logged
881
+ agent_memory_guard_memory_path "$log" || return 1
882
+ pending_tmp=$(mktemp)
883
+ [ -s "$list_tmp" ] || { rm -f "$pending_tmp"; return 0; }
884
+
885
+ reset_logged_files_if_session_changed "$sid"
886
+ session_log_has_target_heading "$sid" || {
887
+ rm -f "$pending_tmp"
888
+ return 0
889
+ }
890
+
891
+ logged=$(read_state logged_files "")
892
+ count=0
893
+ while IFS= read -r f || [ -n "$f" ]; do
894
+ [ -n "$f" ] || continue
895
+ file_already_logged "$f" "$logged" && continue
896
+ printf '%s\n' "$f" >>"$pending_tmp"
897
+ count=$((count + 1))
898
+ done <"$list_tmp"
899
+ [ "$count" -gt 0 ] || { rm -f "$pending_tmp"; return 0; }
900
+
901
+ if [ "$(read_state log_summary_mode "")" = "1" ] && [ "$count" -le 8 ]; then
902
+ while IFS= read -r f || [ -n "$f" ]; do
903
+ [ -n "$f" ] || continue
904
+ if [ -z "$logged" ]; then logged="$f"
905
+ else logged="$logged"$'\x1e'"$f"; fi
906
+ done <"$pending_tmp"
907
+ write_state logged_files "$logged"
908
+ rm -f "$pending_tmp"
909
+ return 0
910
+ fi
911
+
912
+ bullets_tmp=$(mktemp)
913
+ if [ "$count" -le 8 ]; then
914
+ while IFS= read -r f || [ -n "$f" ]; do
915
+ [ -n "$f" ] || continue
916
+ printf '%s\n' "- \`$f\`" >>"$bullets_tmp"
917
+ done <"$pending_tmp"
918
+ else
919
+ printf '%s\n' "- changed ${count} files (see active-work Touched files)" >>"$bullets_tmp"
920
+ write_state log_summary_mode "1"
921
+ fi
922
+
923
+ if awk -v sid="$sid" -v date="$(today_date)" -v bullets="$bullets_tmp" '
924
+ BEGIN {
925
+ while ((getline b < bullets) > 0) bullet[++bn] = b
926
+ close(bullets)
927
+ if (sid != "") {
928
+ heading_pat = "^## \\[[0-9]{4}-[0-9]{2}-[0-9]{2}\\] \\[" sid "\\]"
929
+ }
930
+ }
931
+ function is_legacy_checkpoint(line) {
932
+ return line ~ /hook checkpoint/
933
+ }
934
+ function is_target_heading(line) {
935
+ if (is_legacy_checkpoint(line)) return 0
936
+ if (sid != "") return line ~ heading_pat
937
+ # no-id: target any same-day non-legacy heading, INCLUDING a UUID heading.
938
+ # A no-id sync following a with-id session (state lost/cleared mid-
939
+ # session) continues logging under the prior UUID heading rather than
940
+ # fragmenting. This intentionally diverges from is_no_id_heading, which
941
+ # excludes UUID as a sessionStart creation gate.
942
+ if (line !~ "^## \\[" date "\\]") return 0
943
+ if (line ~ "^## \\[" date "\\]$") return 1
944
+ return line ~ "^## \\[" date "\\] \\["
945
+ }
946
+ {
947
+ buf[++nr] = $0
948
+ }
949
+ END {
950
+ end_section = 0
951
+ for (i = 1; i <= nr; i++) {
952
+ if (is_target_heading(buf[i])) {
953
+ end_section = i
954
+ for (j = i + 1; j <= nr; j++) {
955
+ if (buf[j] ~ /^## /) break
956
+ if (buf[j] ~ /^- /) end_section = j
957
+ }
958
+ }
959
+ }
960
+ if (end_section == 0) exit 1
961
+ for (i = 1; i <= nr; i++) {
962
+ print buf[i]
963
+ if (i == end_section) {
964
+ for (j = 1; j <= bn; j++) print bullet[j]
965
+ }
966
+ }
967
+ }
968
+ ' "$log" >"${log}.tmp" && mv "${log}.tmp" "$log"; then
969
+ while IFS= read -r f || [ -n "$f" ]; do
970
+ [ -n "$f" ] || continue
971
+ if [ -z "$logged" ]; then logged="$f"
972
+ else logged="$logged"$'\x1e'"$f"; fi
973
+ done <"$pending_tmp"
974
+ write_state logged_files "$logged"
975
+ fi
976
+ rm -f "$bullets_tmp" "$pending_tmp"
977
+ }
978
+
979
+ extract_active_work_summary() {
980
+ local aw=$1 branch
981
+ agent_memory_guard_memory_path "$aw" || return 1
982
+ branch=$(basename "$aw" .md)
983
+ awk '
984
+ /^## Task/ { in_task = 1; next }
985
+ in_task && /^## / { exit }
986
+ in_task && /^- / {
987
+ line = $0
988
+ sub(/^- /, "", line)
989
+ if (line !~ /^_No active task\./ && line !~ /^_none_/ && line !~ /refine in session/) {
990
+ print line
991
+ exit
992
+ }
993
+ }
994
+ ' "$aw"
995
+ }
996
+
997
+ refresh_current_in_progress() {
998
+ local current="$memory/current.md" tmp
999
+ [ -f "$current" ] || return 0
1000
+ agent_memory_guard_memory_path "$current" || return 1
1001
+
1002
+ tmp=$(mktemp)
1003
+ {
1004
+ for aw in "$memory"/active-work/*.md; do
1005
+ [ -f "$aw" ] || continue
1006
+ [ "$(basename "$aw")" = "TEMPLATE.md" ] && continue
1007
+ agent_memory_guard_memory_path "$aw" || continue
1008
+ local base summary
1009
+ base=$(basename "$aw")
1010
+ summary=$(extract_active_work_summary "$aw")
1011
+ if [ -z "$summary" ]; then
1012
+ summary=$(branch_to_task_stub "$(basename "$aw" .md)")
1013
+ fi
1014
+ printf -- '- [`active-work/%s`](./active-work/%s) — %s\n' "$base" "$base" "$summary"
1015
+ done
1016
+ } >"$tmp"
1017
+
1018
+ replace_section_bullets "$current" '^## In progress' "$tmp"
1019
+ rm -f "$tmp"
1020
+ }