@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,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
+ }