@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.
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/bin/agent-memory.js +496 -0
- package/hooks/README.md +292 -0
- package/hooks/agent-memory-hooks/agent-memory-common.sh +1020 -0
- package/hooks/agent-memory-hooks/agent-memory-session.sh +98 -0
- package/hooks/agent-memory-hooks/agent-memory-sync.sh +147 -0
- package/hooks/claude-code/settings.json +51 -0
- package/hooks/codex/config.toml.snippet +38 -0
- package/hooks/codex/hooks.json +51 -0
- package/hooks/copilot/agent-memory.json +34 -0
- package/hooks/cursor/hooks.json +36 -0
- package/hooks/gemini/settings.json +50 -0
- package/hooks/git/pre-commit +45 -0
- package/hooks/install-hooks.sh +358 -0
- package/hooks/opencode/agent-memory.ts +320 -0
- package/package.json +22 -0
- package/skills/agent-memory/SKILL.md +185 -0
- package/skills/agent-memory/references/agent-block.md +115 -0
- package/skills/agent-memory/references/bootstrap.md +84 -0
- package/skills/agent-memory/references/init.md +211 -0
- package/skills/agent-memory/references/install-hooks.md +113 -0
- package/skills/agent-memory/references/lint.md +113 -0
- package/skills/agent-memory/references/sync.md +120 -0
- package/skills/agent-memory/references/update.md +145 -0
- package/skills/agent-memory/vendor/README.md +132 -0
- package/skills/agent-memory/vendor/UPDATE.md +369 -0
- package/skills/agent-memory/vendor/memory/active-work/TEMPLATE.md +33 -0
- package/skills/agent-memory/vendor/memory/current.md +34 -0
- package/skills/agent-memory/vendor/memory/decisions.md +30 -0
- package/skills/agent-memory/vendor/memory/index.md +55 -0
- package/skills/agent-memory/vendor/memory/instructions.md +340 -0
- 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
|
+
}
|