@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,358 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Install or refresh agent-memory lifecycle hooks for one harness.
|
|
3
|
+
# Usage: bash install-hooks.sh <harness>
|
|
4
|
+
# Run from the target project root (or set AGENT_MEMORY_PROJECT_DIR).
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
HOOKS_ROOT="$SCRIPT_DIR"
|
|
9
|
+
SHARED_DIR="$HOOKS_ROOT/agent-memory-hooks"
|
|
10
|
+
|
|
11
|
+
# Prefer version from CLI / package; fall back for standalone checkout.
|
|
12
|
+
if [[ -n "${AGENT_MEMORY_VERSION:-}" ]]; then
|
|
13
|
+
VERSION="$AGENT_MEMORY_VERSION"
|
|
14
|
+
elif [[ -f "$SCRIPT_DIR/../package.json" ]] && command -v node >/dev/null 2>&1; then
|
|
15
|
+
VERSION="$(
|
|
16
|
+
node -p 'require(process.argv[1]).version' "$SCRIPT_DIR/../package.json" 2>/dev/null || true
|
|
17
|
+
)"
|
|
18
|
+
fi
|
|
19
|
+
VERSION="${VERSION:-0.0.13}"
|
|
20
|
+
|
|
21
|
+
# Resolve project dir (absolute). Relative AGENT_MEMORY_PROJECT_DIR is allowed.
|
|
22
|
+
# Require an existing directory so realpath and python3 agree (no mkdir surprise).
|
|
23
|
+
_raw_project="${AGENT_MEMORY_PROJECT_DIR:-$(pwd)}"
|
|
24
|
+
if [[ ! -d "$_raw_project" ]]; then
|
|
25
|
+
echo "error: PROJECT_DIR does not exist: $_raw_project" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
if command -v realpath >/dev/null 2>&1; then
|
|
29
|
+
PROJECT_DIR="$(realpath "$_raw_project")"
|
|
30
|
+
elif command -v python3 >/dev/null 2>&1; then
|
|
31
|
+
PROJECT_DIR="$(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$_raw_project")"
|
|
32
|
+
else
|
|
33
|
+
PROJECT_DIR="$(cd "$_raw_project" && pwd)"
|
|
34
|
+
fi
|
|
35
|
+
unset _raw_project
|
|
36
|
+
|
|
37
|
+
usage() {
|
|
38
|
+
cat <<EOF
|
|
39
|
+
Usage: bash install-hooks.sh <harness>
|
|
40
|
+
|
|
41
|
+
Install or refresh agent-memory lifecycle hooks into the current project.
|
|
42
|
+
|
|
43
|
+
Harnesses: cursor, claude (claude-code), codex, opencode, copilot (github), gemini
|
|
44
|
+
|
|
45
|
+
Version: ${VERSION}
|
|
46
|
+
EOF
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
die() {
|
|
50
|
+
echo "error: $*" >&2
|
|
51
|
+
exit 1
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
resolve_realpath() {
|
|
55
|
+
local p=$1
|
|
56
|
+
if command -v realpath >/dev/null 2>&1; then
|
|
57
|
+
realpath "$p"
|
|
58
|
+
elif command -v python3 >/dev/null 2>&1; then
|
|
59
|
+
python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$p"
|
|
60
|
+
else
|
|
61
|
+
printf '%s\n' "$(cd "$(dirname "$p")" && pwd)/$(basename "$p")"
|
|
62
|
+
fi
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Walk PROJECT_DIR → dest; refuse if any existing component is a symlink.
|
|
66
|
+
refuse_symlink_components() {
|
|
67
|
+
local dest=$1 cur rest part
|
|
68
|
+
if [[ "$dest" != "$PROJECT_DIR"/* ]]; then
|
|
69
|
+
die "destination escapes project: $dest"
|
|
70
|
+
fi
|
|
71
|
+
cur="$PROJECT_DIR"
|
|
72
|
+
rest="${dest#"$PROJECT_DIR"/}"
|
|
73
|
+
while [[ -n "$rest" ]]; do
|
|
74
|
+
if [[ "$rest" == */* ]]; then
|
|
75
|
+
part="${rest%%/*}"
|
|
76
|
+
rest="${rest#*/}"
|
|
77
|
+
else
|
|
78
|
+
part="$rest"
|
|
79
|
+
rest=""
|
|
80
|
+
fi
|
|
81
|
+
cur="$cur/$part"
|
|
82
|
+
if [[ -e "$cur" || -L "$cur" ]] && [[ -L "$cur" ]]; then
|
|
83
|
+
die "refusing symlink in destination path: $cur"
|
|
84
|
+
fi
|
|
85
|
+
done
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# After mkdir -p, ensure the resolved parent stays under PROJECT_DIR.
|
|
89
|
+
ensure_resolved_under_project() {
|
|
90
|
+
local dest=$1 dir parent
|
|
91
|
+
dir="$(dirname "$dest")"
|
|
92
|
+
mkdir -p "$dir"
|
|
93
|
+
parent="$(resolve_realpath "$dir")" || die "cannot resolve parent: $dir"
|
|
94
|
+
case "$parent" in
|
|
95
|
+
"$PROJECT_DIR" | "$PROJECT_DIR"/*) ;;
|
|
96
|
+
*) die "resolved parent escapes project: $parent" ;;
|
|
97
|
+
esac
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
need_cmd() {
|
|
101
|
+
command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
normalize_harness() {
|
|
105
|
+
case "$1" in
|
|
106
|
+
cursor) echo cursor ;;
|
|
107
|
+
claude | claude-code) echo claude ;;
|
|
108
|
+
codex) echo codex ;;
|
|
109
|
+
opencode) echo opencode ;;
|
|
110
|
+
copilot | github) echo copilot ;;
|
|
111
|
+
gemini) echo gemini ;;
|
|
112
|
+
*) return 1 ;;
|
|
113
|
+
esac
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
prereq_dir_for() {
|
|
117
|
+
case "$1" in
|
|
118
|
+
cursor) echo .cursor ;;
|
|
119
|
+
claude) echo .claude ;;
|
|
120
|
+
codex) echo .codex ;;
|
|
121
|
+
opencode) echo .opencode ;;
|
|
122
|
+
copilot) echo .github ;;
|
|
123
|
+
gemini) echo .gemini ;;
|
|
124
|
+
esac
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
hooks_dir_for() {
|
|
128
|
+
case "$1" in
|
|
129
|
+
cursor) echo .cursor/hooks ;;
|
|
130
|
+
claude) echo .claude/hooks ;;
|
|
131
|
+
codex) echo .codex/hooks ;;
|
|
132
|
+
opencode) echo .opencode/hooks ;;
|
|
133
|
+
copilot) echo .github/hooks ;;
|
|
134
|
+
gemini) echo .gemini/hooks ;;
|
|
135
|
+
esac
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Copy src → dest without following a destination symlink (refuse + abort).
|
|
139
|
+
# Uses temp + mv so a regular-file replace does not follow links.
|
|
140
|
+
safe_install_file() {
|
|
141
|
+
local src=$1 dest=$2 tmp
|
|
142
|
+
[[ -f "$src" ]] || die "missing source: $src"
|
|
143
|
+
if [[ -L "$dest" ]]; then
|
|
144
|
+
die "refusing to overwrite symlink: $dest"
|
|
145
|
+
fi
|
|
146
|
+
refuse_symlink_components "$dest"
|
|
147
|
+
ensure_resolved_under_project "$dest"
|
|
148
|
+
tmp="$(mktemp "${dest}.XXXXXX")"
|
|
149
|
+
cp "$src" "$tmp"
|
|
150
|
+
mv "$tmp" "$dest"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Merge agent-memory hook entries into an existing JSON config.
|
|
154
|
+
# Args: source_json target_json out_json mode
|
|
155
|
+
# mode: flat (cursor/copilot — hooks.<event> = array)
|
|
156
|
+
# nested (claude/codex/gemini — hooks.<event> = [{matcher?, hooks: [...]}])
|
|
157
|
+
merge_hooks_json() {
|
|
158
|
+
local source=$1 target=$2 out=$3 mode=$4
|
|
159
|
+
need_cmd node
|
|
160
|
+
node --input-type=module - "$source" "$target" "$out" "$mode" <<'NODE'
|
|
161
|
+
import fs from 'node:fs';
|
|
162
|
+
|
|
163
|
+
const [sourcePath, targetPath, outPath, mode] = process.argv.slice(2);
|
|
164
|
+
const src = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
|
|
165
|
+
let tgt = {};
|
|
166
|
+
if (fs.existsSync(targetPath)) {
|
|
167
|
+
tgt = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Product hook entries invoke our scripts by path/basename, optionally with
|
|
172
|
+
* AGENT_MEMORY_* env prefixes. Do not match mere mentions of the filenames
|
|
173
|
+
* (e.g. docs/agent-memory-sync.sh.example).
|
|
174
|
+
*/
|
|
175
|
+
const isOurs = (value) => {
|
|
176
|
+
const s = typeof value === 'string' ? value : JSON.stringify(value);
|
|
177
|
+
return /(?:^|[\s/"'=])(?:[\w./-]*\/)?agent-memory-(?:session|sync|common)\.sh(?:$|[\s"'])/.test(
|
|
178
|
+
s
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const asHooksObject = (hooks) => {
|
|
183
|
+
if (hooks && typeof hooks === 'object' && !Array.isArray(hooks)) return hooks;
|
|
184
|
+
return {};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** Nested groups: strip only our command entries; keep sibling custom hooks. */
|
|
188
|
+
const scrubNestedGroup = (group) => {
|
|
189
|
+
if (!group || typeof group !== 'object' || Array.isArray(group)) return null;
|
|
190
|
+
const next = { ...group };
|
|
191
|
+
if (Array.isArray(next.hooks)) {
|
|
192
|
+
next.hooks = next.hooks.filter((entry) => !isOurs(entry));
|
|
193
|
+
if (next.hooks.length === 0) return null;
|
|
194
|
+
} else if (isOurs(group)) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return next;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if (mode === 'flat') {
|
|
201
|
+
if (src.version != null && tgt.version == null) tgt.version = src.version;
|
|
202
|
+
tgt.hooks = asHooksObject(tgt.hooks);
|
|
203
|
+
const srcHooks = src.hooks || {};
|
|
204
|
+
for (const [event, entries] of Object.entries(srcHooks)) {
|
|
205
|
+
const existing = Array.isArray(tgt.hooks[event]) ? tgt.hooks[event] : [];
|
|
206
|
+
const kept = existing.filter((e) => !isOurs(e));
|
|
207
|
+
tgt.hooks[event] = [...kept, ...(Array.isArray(entries) ? entries : [])];
|
|
208
|
+
}
|
|
209
|
+
} else if (mode === 'nested') {
|
|
210
|
+
tgt.hooks = asHooksObject(tgt.hooks);
|
|
211
|
+
const srcHooks = src.hooks || {};
|
|
212
|
+
for (const [event, groups] of Object.entries(srcHooks)) {
|
|
213
|
+
const existing = Array.isArray(tgt.hooks[event]) ? tgt.hooks[event] : [];
|
|
214
|
+
const kept = [];
|
|
215
|
+
for (const group of existing) {
|
|
216
|
+
const scrubbed = scrubNestedGroup(group);
|
|
217
|
+
if (scrubbed) kept.push(scrubbed);
|
|
218
|
+
}
|
|
219
|
+
tgt.hooks[event] = [...kept, ...(Array.isArray(groups) ? groups : [])];
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
console.error(`unknown merge mode: ${mode}`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fs.writeFileSync(outPath, `${JSON.stringify(tgt, null, 2)}\n`);
|
|
227
|
+
NODE
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# Write merged JSON to a temp file beside the target, then replace atomically.
|
|
231
|
+
# Refuse if the target path is a symlink (same trust rule as safe_install_file).
|
|
232
|
+
merge_into() {
|
|
233
|
+
local src=$1 tgt=$2 mode=$3 tmp
|
|
234
|
+
if [[ -L "$tgt" ]]; then
|
|
235
|
+
die "refusing to overwrite symlink: $tgt"
|
|
236
|
+
fi
|
|
237
|
+
refuse_symlink_components "$tgt"
|
|
238
|
+
ensure_resolved_under_project "$tgt"
|
|
239
|
+
tmp="$(mktemp "${tgt}.XXXXXX")"
|
|
240
|
+
# If target missing, merge still works (empty tgt).
|
|
241
|
+
if ! merge_hooks_json "$src" "$tgt" "$tmp" "$mode"; then
|
|
242
|
+
rm -f "$tmp"
|
|
243
|
+
die "failed to merge hooks config into $tgt"
|
|
244
|
+
fi
|
|
245
|
+
mv "$tmp" "$tgt"
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
install_shared_scripts() {
|
|
249
|
+
local dest=$1
|
|
250
|
+
local f
|
|
251
|
+
for f in agent-memory-common.sh agent-memory-sync.sh agent-memory-session.sh; do
|
|
252
|
+
[[ -f "$SHARED_DIR/$f" ]] || die "missing shared script: $SHARED_DIR/$f"
|
|
253
|
+
safe_install_file "$SHARED_DIR/$f" "$PROJECT_DIR/$dest/$f"
|
|
254
|
+
chmod +x "$PROJECT_DIR/$dest/$f"
|
|
255
|
+
done
|
|
256
|
+
echo "copied shared scripts → $dest/"
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
install_cursor() {
|
|
260
|
+
local hooks_dir
|
|
261
|
+
hooks_dir="$(hooks_dir_for cursor)"
|
|
262
|
+
local src="$HOOKS_ROOT/cursor/hooks.json"
|
|
263
|
+
local tgt="$PROJECT_DIR/.cursor/hooks.json"
|
|
264
|
+
# Scripts first so a failed cp never leaves config pointing at missing files.
|
|
265
|
+
install_shared_scripts "$hooks_dir"
|
|
266
|
+
merge_into "$src" "$tgt" flat
|
|
267
|
+
echo "merged cursor hooks → .cursor/hooks.json"
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
install_claude() {
|
|
271
|
+
local hooks_dir
|
|
272
|
+
hooks_dir="$(hooks_dir_for claude)"
|
|
273
|
+
local src="$HOOKS_ROOT/claude-code/settings.json"
|
|
274
|
+
local tgt="$PROJECT_DIR/.claude/settings.json"
|
|
275
|
+
install_shared_scripts "$hooks_dir"
|
|
276
|
+
merge_into "$src" "$tgt" nested
|
|
277
|
+
echo "merged claude settings → .claude/settings.json"
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
install_codex() {
|
|
281
|
+
local hooks_dir
|
|
282
|
+
hooks_dir="$(hooks_dir_for codex)"
|
|
283
|
+
local src="$HOOKS_ROOT/codex/hooks.json"
|
|
284
|
+
local tgt="$PROJECT_DIR/.codex/hooks.json"
|
|
285
|
+
install_shared_scripts "$hooks_dir"
|
|
286
|
+
merge_into "$src" "$tgt" nested
|
|
287
|
+
echo "merged codex hooks → .codex/hooks.json"
|
|
288
|
+
echo "reminder: run /hooks in the Codex TUI to trust project hooks"
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
install_opencode() {
|
|
292
|
+
local hooks_dir
|
|
293
|
+
hooks_dir="$(hooks_dir_for opencode)"
|
|
294
|
+
install_shared_scripts "$hooks_dir"
|
|
295
|
+
safe_install_file \
|
|
296
|
+
"$HOOKS_ROOT/opencode/agent-memory.ts" \
|
|
297
|
+
"$PROJECT_DIR/.opencode/plugin/agent-memory.ts"
|
|
298
|
+
echo "copied OpenCode plugin → .opencode/plugin/agent-memory.ts"
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
install_copilot() {
|
|
302
|
+
local hooks_dir
|
|
303
|
+
hooks_dir="$(hooks_dir_for copilot)"
|
|
304
|
+
local src="$HOOKS_ROOT/copilot/agent-memory.json"
|
|
305
|
+
local tgt="$PROJECT_DIR/.github/hooks/agent-memory.json"
|
|
306
|
+
install_shared_scripts "$hooks_dir"
|
|
307
|
+
if [[ ! -f "$tgt" ]]; then
|
|
308
|
+
safe_install_file "$src" "$tgt"
|
|
309
|
+
echo "copied copilot hooks → .github/hooks/agent-memory.json"
|
|
310
|
+
else
|
|
311
|
+
merge_into "$src" "$tgt" flat
|
|
312
|
+
echo "merged copilot hooks → .github/hooks/agent-memory.json"
|
|
313
|
+
fi
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
install_gemini() {
|
|
317
|
+
local hooks_dir
|
|
318
|
+
hooks_dir="$(hooks_dir_for gemini)"
|
|
319
|
+
local src="$HOOKS_ROOT/gemini/settings.json"
|
|
320
|
+
local tgt="$PROJECT_DIR/.gemini/settings.json"
|
|
321
|
+
install_shared_scripts "$hooks_dir"
|
|
322
|
+
merge_into "$src" "$tgt" nested
|
|
323
|
+
echo "merged gemini settings → .gemini/settings.json"
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
main() {
|
|
327
|
+
if [[ $# -lt 1 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
|
|
328
|
+
usage
|
|
329
|
+
exit 0
|
|
330
|
+
fi
|
|
331
|
+
|
|
332
|
+
[[ -d "$SHARED_DIR" ]] || die "shared hooks not found at $SHARED_DIR"
|
|
333
|
+
|
|
334
|
+
local harness
|
|
335
|
+
harness="$(normalize_harness "$1")" || die "unknown harness: $1 (see --help)"
|
|
336
|
+
|
|
337
|
+
local prereq
|
|
338
|
+
prereq="$(prereq_dir_for "$harness")"
|
|
339
|
+
refuse_symlink_components "$PROJECT_DIR/$prereq"
|
|
340
|
+
if [[ ! -d "$PROJECT_DIR/$prereq" ]]; then
|
|
341
|
+
ensure_resolved_under_project "$PROJECT_DIR/$prereq/.install-sentinel"
|
|
342
|
+
rm -f "$PROJECT_DIR/$prereq/.install-sentinel"
|
|
343
|
+
echo "created $prereq/"
|
|
344
|
+
fi
|
|
345
|
+
|
|
346
|
+
case "$harness" in
|
|
347
|
+
cursor) install_cursor ;;
|
|
348
|
+
claude) install_claude ;;
|
|
349
|
+
codex) install_codex ;;
|
|
350
|
+
opencode) install_opencode ;;
|
|
351
|
+
copilot) install_copilot ;;
|
|
352
|
+
gemini) install_gemini ;;
|
|
353
|
+
esac
|
|
354
|
+
|
|
355
|
+
echo "done: agent-memory hooks installed for $harness (v${VERSION})"
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
main "$@"
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// OpenCode plugin: deterministic agent-memory checkpoints.
|
|
2
|
+
// Spawns the shared shell scripts — no LLM call.
|
|
3
|
+
//
|
|
4
|
+
// OpenCode has no native sessionStart hook JSON; this plugin bridges plugin
|
|
5
|
+
// events to the same scripts used by Cursor, Claude, Codex, and Copilot.
|
|
6
|
+
//
|
|
7
|
+
// OpenCode rotates `ses_*` session IDs across idle/compaction within a work day.
|
|
8
|
+
// The shell helpers coalesce log headings (one per calendar day); this plugin
|
|
9
|
+
// skips redundant sessionStart runs once a heading is bound in .hook-sync-state.
|
|
10
|
+
//
|
|
11
|
+
// Install (see hooks/README.md):
|
|
12
|
+
// hooks/agent-memory-hooks/agent-memory-common.sh
|
|
13
|
+
// hooks/agent-memory-hooks/agent-memory-session.sh
|
|
14
|
+
// hooks/agent-memory-hooks/agent-memory-sync.sh → .opencode/hooks/
|
|
15
|
+
// this file → .opencode/plugin/agent-memory.ts
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
|
|
21
|
+
const HOOKS_DIR = '.opencode/hooks';
|
|
22
|
+
const SESSION_SCRIPT = `${HOOKS_DIR}/agent-memory-session.sh`;
|
|
23
|
+
const SYNC_SCRIPT = `${HOOKS_DIR}/agent-memory-sync.sh`;
|
|
24
|
+
const STATE_FILE = path.join('.agents', 'memory', '.hook-sync-state');
|
|
25
|
+
|
|
26
|
+
function hasMemory(): boolean {
|
|
27
|
+
return fs.existsSync(path.join(process.cwd(), '.agents', 'memory'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readHookStateValue(key: string): string | undefined {
|
|
31
|
+
try {
|
|
32
|
+
const statePath = path.join(process.cwd(), STATE_FILE);
|
|
33
|
+
if (!fs.existsSync(statePath)) return undefined;
|
|
34
|
+
for (const line of fs.readFileSync(statePath, 'utf8').split('\n')) {
|
|
35
|
+
const eq = line.indexOf('=');
|
|
36
|
+
if (eq > 0 && line.slice(0, eq) === key) {
|
|
37
|
+
const val = line.slice(eq + 1);
|
|
38
|
+
return val.length > 0 ? val : undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
/* fail open */
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function opencodeLogHeadingBoundToday(): boolean {
|
|
48
|
+
const today = todayLocalDate();
|
|
49
|
+
const bound = readHookStateValue('opencode_log_heading_id');
|
|
50
|
+
const stateDate = readHookStateValue('opencode_log_date');
|
|
51
|
+
if (stateDate !== today || !bound) return false;
|
|
52
|
+
try {
|
|
53
|
+
const logPath = path.join(process.cwd(), '.agents', 'memory', 'log.md');
|
|
54
|
+
if (!fs.existsSync(logPath)) return false;
|
|
55
|
+
const log = fs.readFileSync(logPath, 'utf8');
|
|
56
|
+
return log.includes(`## [${today}] [${bound}]`);
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractSessionId(input: unknown): string | undefined {
|
|
63
|
+
if (!input || typeof input !== 'object') return undefined;
|
|
64
|
+
const root = input as Record<string, unknown>;
|
|
65
|
+
const event = root.event as Record<string, unknown> | undefined;
|
|
66
|
+
const props = event?.properties as Record<string, unknown> | undefined;
|
|
67
|
+
for (const candidate of [
|
|
68
|
+
root.sessionID,
|
|
69
|
+
root.session_id,
|
|
70
|
+
event?.sessionID,
|
|
71
|
+
event?.session_id,
|
|
72
|
+
props?.sessionID,
|
|
73
|
+
props?.session_id,
|
|
74
|
+
]) {
|
|
75
|
+
if (typeof candidate === 'string' && candidate.length > 0) return candidate;
|
|
76
|
+
}
|
|
77
|
+
const fromEnv = process.env.AGENT_MEMORY_SESSION_ID;
|
|
78
|
+
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function extractConversationId(input: unknown): string | undefined {
|
|
82
|
+
if (!input || typeof input !== 'object') return undefined;
|
|
83
|
+
const root = input as Record<string, unknown>;
|
|
84
|
+
const event = root.event as Record<string, unknown> | undefined;
|
|
85
|
+
const props = event?.properties as Record<string, unknown> | undefined;
|
|
86
|
+
for (const candidate of [
|
|
87
|
+
root.conversationID,
|
|
88
|
+
root.conversation_id,
|
|
89
|
+
event?.conversationID,
|
|
90
|
+
event?.conversation_id,
|
|
91
|
+
props?.conversationID,
|
|
92
|
+
props?.conversation_id,
|
|
93
|
+
]) {
|
|
94
|
+
if (typeof candidate === 'string' && candidate.length > 0) return candidate;
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Env keys forwarded to hook scripts (avoid leaking full parent env).
|
|
101
|
+
* Keep in sync with bin/agent-memory.js ENV_ALLOWLIST_EXACT.
|
|
102
|
+
*/
|
|
103
|
+
const ENV_ALLOWLIST_EXACT = new Set([
|
|
104
|
+
'PATH',
|
|
105
|
+
'HOME',
|
|
106
|
+
'USER',
|
|
107
|
+
'SHELL',
|
|
108
|
+
'TMPDIR',
|
|
109
|
+
'TMP',
|
|
110
|
+
'TEMP',
|
|
111
|
+
'LANG',
|
|
112
|
+
'TZ',
|
|
113
|
+
// Windows
|
|
114
|
+
'SystemRoot',
|
|
115
|
+
'SYSTEMROOT',
|
|
116
|
+
'windir',
|
|
117
|
+
'WINDIR',
|
|
118
|
+
'USERPROFILE',
|
|
119
|
+
'HOMEDRIVE',
|
|
120
|
+
'HOMEPATH',
|
|
121
|
+
'ComSpec',
|
|
122
|
+
'COMSPEC',
|
|
123
|
+
'PATHEXT',
|
|
124
|
+
// Git / XDG
|
|
125
|
+
'XDG_CONFIG_HOME',
|
|
126
|
+
'XDG_DATA_HOME',
|
|
127
|
+
'GIT_CONFIG_GLOBAL',
|
|
128
|
+
'GIT_CONFIG_SYSTEM',
|
|
129
|
+
'GIT_CONFIG',
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
/** Local calendar date (YYYY-MM-DD), aligned with shell `date +%Y-%m-%d`. */
|
|
133
|
+
function todayLocalDate(): string {
|
|
134
|
+
const d = new Date();
|
|
135
|
+
const y = d.getFullYear();
|
|
136
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
137
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
138
|
+
return `${y}-${m}-${day}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildChildEnv(
|
|
142
|
+
host: string,
|
|
143
|
+
event: string,
|
|
144
|
+
sessionId?: string
|
|
145
|
+
): NodeJS.ProcessEnv {
|
|
146
|
+
const env: NodeJS.ProcessEnv = {};
|
|
147
|
+
for (const key of Object.keys(process.env)) {
|
|
148
|
+
if (ENV_ALLOWLIST_EXACT.has(key) || key.startsWith('LC_')) {
|
|
149
|
+
const val = process.env[key];
|
|
150
|
+
if (val !== undefined) env[key] = val;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
env.AGENT_MEMORY_HOST = host;
|
|
154
|
+
env.AGENT_MEMORY_EVENT = event;
|
|
155
|
+
env.AGENT_MEMORY_PROJECT_DIR = process.cwd();
|
|
156
|
+
if (sessionId) env.AGENT_MEMORY_SESSION_ID = sessionId;
|
|
157
|
+
return env;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function runScript(
|
|
161
|
+
script: string,
|
|
162
|
+
event: string,
|
|
163
|
+
host: string,
|
|
164
|
+
sessionId?: string,
|
|
165
|
+
conversationId?: string
|
|
166
|
+
): boolean {
|
|
167
|
+
const cwd = process.cwd();
|
|
168
|
+
const scriptPath = path.join(cwd, script);
|
|
169
|
+
if (!fs.existsSync(scriptPath)) return false;
|
|
170
|
+
const payload: Record<string, string> = {};
|
|
171
|
+
if (sessionId) payload.session_id = sessionId;
|
|
172
|
+
else if (conversationId) payload.conversation_id = conversationId;
|
|
173
|
+
const stdinPayload =
|
|
174
|
+
Object.keys(payload).length > 0 ? JSON.stringify(payload) : undefined;
|
|
175
|
+
try {
|
|
176
|
+
execFileSync('bash', [scriptPath], {
|
|
177
|
+
cwd,
|
|
178
|
+
env: buildChildEnv(host, event, sessionId),
|
|
179
|
+
input: stdinPayload,
|
|
180
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
181
|
+
timeout: 15_000,
|
|
182
|
+
});
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const sessionInitializedFor = new Set<string>();
|
|
190
|
+
const NO_SESSION_ID_KEY = '__no_session_id__';
|
|
191
|
+
let activeConversationId: string | undefined;
|
|
192
|
+
|
|
193
|
+
function clearScopeAndNoIdDedupeKeys(): void {
|
|
194
|
+
sessionInitializedFor.delete(NO_SESSION_ID_KEY);
|
|
195
|
+
for (const key of sessionInitializedFor) {
|
|
196
|
+
if (key.startsWith('scope:')) sessionInitializedFor.delete(key);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function extractOpenCodeSessionScope(input: unknown): string | undefined {
|
|
201
|
+
if (!input || typeof input !== 'object') return undefined;
|
|
202
|
+
const root = input as Record<string, unknown>;
|
|
203
|
+
const event = root.event as Record<string, unknown> | undefined;
|
|
204
|
+
const session = (event?.session ?? root.session) as
|
|
205
|
+
| Record<string, unknown>
|
|
206
|
+
| undefined;
|
|
207
|
+
for (const candidate of [
|
|
208
|
+
session?.id,
|
|
209
|
+
session?.sessionID,
|
|
210
|
+
session?.session_id,
|
|
211
|
+
event?.id,
|
|
212
|
+
]) {
|
|
213
|
+
if (typeof candidate === 'string' && candidate.length > 0) return candidate;
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function markSessionInitialized(
|
|
219
|
+
sessionId?: string,
|
|
220
|
+
conversationId?: string,
|
|
221
|
+
scopeId?: string
|
|
222
|
+
): void {
|
|
223
|
+
if (conversationId) sessionInitializedFor.add(`conv:${conversationId}`);
|
|
224
|
+
if (sessionId) sessionInitializedFor.add(sessionId);
|
|
225
|
+
if (scopeId) sessionInitializedFor.add(`scope:${scopeId}`);
|
|
226
|
+
sessionInitializedFor.add(NO_SESSION_ID_KEY);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function ensureSessionStart(sessionId?: string, input?: unknown): void {
|
|
230
|
+
if (opencodeLogHeadingBoundToday()) {
|
|
231
|
+
markSessionInitialized(
|
|
232
|
+
sessionId,
|
|
233
|
+
extractConversationId(input) ?? activeConversationId,
|
|
234
|
+
extractOpenCodeSessionScope(input)
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const conversationId = extractConversationId(input) ?? activeConversationId;
|
|
240
|
+
const freshConversationId = extractConversationId(input);
|
|
241
|
+
const scopeId = extractOpenCodeSessionScope(input);
|
|
242
|
+
if (freshConversationId && freshConversationId !== activeConversationId) {
|
|
243
|
+
if (activeConversationId) {
|
|
244
|
+
sessionInitializedFor.delete(`conv:${activeConversationId}`);
|
|
245
|
+
}
|
|
246
|
+
clearScopeAndNoIdDedupeKeys();
|
|
247
|
+
activeConversationId = freshConversationId;
|
|
248
|
+
} else if (freshConversationId) {
|
|
249
|
+
activeConversationId = freshConversationId;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (conversationId) {
|
|
253
|
+
const convKey = `conv:${conversationId}`;
|
|
254
|
+
if (sessionInitializedFor.has(convKey)) {
|
|
255
|
+
if (sessionId) sessionInitializedFor.add(sessionId);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (
|
|
259
|
+
runScript(
|
|
260
|
+
SESSION_SCRIPT,
|
|
261
|
+
'sessionStart',
|
|
262
|
+
'opencode',
|
|
263
|
+
sessionId,
|
|
264
|
+
conversationId
|
|
265
|
+
)
|
|
266
|
+
) {
|
|
267
|
+
markSessionInitialized(sessionId, conversationId, scopeId);
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (sessionId) {
|
|
273
|
+
if (sessionInitializedFor.has(sessionId)) return;
|
|
274
|
+
if (runScript(SESSION_SCRIPT, 'sessionStart', 'opencode', sessionId)) {
|
|
275
|
+
markSessionInitialized(sessionId, undefined, scopeId);
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const scopeKey = scopeId ? `scope:${scopeId}` : NO_SESSION_ID_KEY;
|
|
281
|
+
if (sessionInitializedFor.has(scopeKey)) return;
|
|
282
|
+
if (runScript(SESSION_SCRIPT, 'sessionStart', 'opencode', undefined)) {
|
|
283
|
+
markSessionInitialized(undefined, undefined, scopeId);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function runSync(
|
|
288
|
+
event: string,
|
|
289
|
+
sessionId?: string,
|
|
290
|
+
input?: unknown,
|
|
291
|
+
options?: { sessionStart?: boolean }
|
|
292
|
+
): void {
|
|
293
|
+
if (!hasMemory()) return;
|
|
294
|
+
const conversationId = extractConversationId(input);
|
|
295
|
+
const withSessionStart = options?.sessionStart !== false;
|
|
296
|
+
if (withSessionStart) {
|
|
297
|
+
ensureSessionStart(sessionId, input);
|
|
298
|
+
}
|
|
299
|
+
runScript(SYNC_SCRIPT, event, 'opencode', sessionId, conversationId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export const agentMemoryPlugin = async () => {
|
|
303
|
+
if (!hasMemory()) return {};
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
'experimental.session.compacting': async (input: unknown) => {
|
|
307
|
+
const sessionId = extractSessionId(input);
|
|
308
|
+
// Compaction continues the same work day — no new log heading.
|
|
309
|
+
runSync('PreCompact', sessionId, input, { sessionStart: false });
|
|
310
|
+
},
|
|
311
|
+
event: async (input: { event: { type: string; sessionID?: string } }) => {
|
|
312
|
+
if (input?.event?.type === 'session.idle') {
|
|
313
|
+
const sessionId = extractSessionId(input);
|
|
314
|
+
runSync('Stop', sessionId, input);
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export default agentMemoryPlugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dosx/agent-memory",
|
|
3
|
+
"version": "0.0.13",
|
|
4
|
+
"description": "Local Markdown workspace memory for AI coding agents (Cursor, Claude Code, Codex, OpenCode, Copilot, Gemini). npx CLI installs harness lifecycle hooks; install the skill with npx skills add — no server, vector DB, or embeddings.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"agent-memory": "bin/agent-memory.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"hooks/",
|
|
11
|
+
"skills/agent-memory/"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/diegoos/agent-memory.git"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"packageManager": "pnpm@11.11.0+sha512.4463f65fd80ed80d69bc1d4bf163ee94f605c7380fc318bb5b2ebe15f8cd12d49c51a4d59e951b401e764d3b6ca751cbf51bc50ed7001a6bcb4935e684c34882"
|
|
22
|
+
}
|