@agfpd/iapeer-memory 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/bin/iapeer-memory +29 -0
- package/package.json +36 -0
- package/src/binary.ts +75 -0
- package/src/cli.ts +120 -0
- package/src/commands/fm-update.ts +104 -0
- package/src/commands/hook.ts +264 -0
- package/src/commands/init.ts +358 -0
- package/src/commands/install-binary.ts +44 -0
- package/src/commands/memoryd.ts +101 -0
- package/src/commands/migrate.ts +112 -0
- package/src/commands/render.ts +199 -0
- package/src/commands/status.ts +155 -0
- package/src/commands/uninstall.ts +126 -0
- package/src/commands/update.ts +138 -0
- package/src/commands/verify.ts +300 -0
- package/src/config-env.ts +74 -0
- package/src/paths.ts +101 -0
- package/src/provision.ts +188 -0
- package/src/roles.ts +43 -0
- package/src/slot.ts +89 -0
- package/src/sync-versions.ts +110 -0
- package/src/templates/guide-en.ts +103 -0
- package/src/templates/guide-ru.ts +99 -0
- package/src/templates/index.ts +105 -0
- package/src/templates/roles-en.ts +232 -0
- package/src/templates/roles-ru.ts +224 -0
- package/src/version.ts +17 -0
- package/src/watcher.ts +175 -0
- package/tsconfig.json +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# npx @agfpd/iapeer-memory bootstrap.
|
|
3
|
+
#
|
|
4
|
+
# Runs the iapeer-memory CLI from the package SOURCE via bun — the package is
|
|
5
|
+
# TypeScript-only, no precompiled JS is shipped (ADR-003; same pattern as the
|
|
6
|
+
# @agfpd/iapeer foundation bin). All args pass straight through to the CLI;
|
|
7
|
+
# a BARE invocation prints usage — provisioning is always explicit:
|
|
8
|
+
# `npx @agfpd/iapeer-memory init`.
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
# Resolve the real path of this script BEFORE computing the package root.
|
|
12
|
+
# npm/npx expose the bin as a SYMLINK at node_modules/.bin/iapeer-memory →
|
|
13
|
+
# ../@agfpd/iapeer-memory/bin/iapeer-memory; without resolving it, dirname/..
|
|
14
|
+
# lands on node_modules (not the package dir) and src/cli.ts is not found.
|
|
15
|
+
SOURCE="${BASH_SOURCE[0]}"
|
|
16
|
+
while [ -L "$SOURCE" ]; do
|
|
17
|
+
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
18
|
+
SOURCE="$(readlink "$SOURCE")"
|
|
19
|
+
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
|
|
20
|
+
done
|
|
21
|
+
PKG_ROOT="$(cd -P "$(dirname "$SOURCE")/.." && pwd)"
|
|
22
|
+
CLI="$PKG_ROOT/src/cli.ts"
|
|
23
|
+
|
|
24
|
+
if ! command -v bun >/dev/null 2>&1; then
|
|
25
|
+
echo "iapeer-memory: 'bun' is required on PATH to run the CLI — install it from https://bun.sh" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
exec bun "$CLI" "$@"
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agfpd/iapeer-memory",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "iapeer-memory — peer memory for the iapeer ecosystem: vault, memoryd (index/search/MCP-http), layer-5 context fragments, role doctrines. The package IS the system; the claude/codex plugins are thin session sockets (docs/10-distribution.md, ADR-009).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"iapeer-memory": "bin/iapeer-memory"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"bin",
|
|
13
|
+
"tsconfig.json"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "bun test",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"preversion": "bun run test && bun run typecheck",
|
|
19
|
+
"version": "bun src/sync-versions.ts && git add -A ..",
|
|
20
|
+
"release": "npm version patch --workspaces-update=false && npm run release:finish",
|
|
21
|
+
"release:minor": "npm version minor --workspaces-update=false && npm run release:finish",
|
|
22
|
+
"release:major": "npm version major --workspaces-update=false && npm run release:finish",
|
|
23
|
+
"release:finish": "V=$(bun -e 'console.log(JSON.parse(require(\"fs\").readFileSync(\"package.json\",\"utf-8\")).version)') && git -C .. commit -m \"$V\" && git -C .. tag \"v$V\" && (cd ../core && npm publish) && npm publish && git push --follow-tags",
|
|
24
|
+
"prepublishOnly": "test -z \"$(git status --porcelain)\" || (echo 'release: working tree is dirty — commit or stash before release' >&2 && exit 1)"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@agfpd/iapeer-memory-core": "0.1.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/bun": "^1.2.0",
|
|
34
|
+
"typescript": "^5.7.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/binary.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable CLI binary — `bun build --compile` into `~/.local/bin/iapeer-memory`
|
|
3
|
+
* (the @agfpd/iapeer precedent: notifier/telegram runtime packages ship the
|
|
4
|
+
* same Mach-O form). WHY a compiled binary and not the npx cache: hooks,
|
|
5
|
+
* the notifier watcher launcher and the shims need a path that survives
|
|
6
|
+
* npx-cache eviction — the proven MergeMind production defect class
|
|
7
|
+
* («daemons executing a deleted cache snapshot») is closed by owning a
|
|
8
|
+
* stable artifact (ADR-010).
|
|
9
|
+
*
|
|
10
|
+
* Facts from the P3a compile check (live, bun 1.3.13):
|
|
11
|
+
* - the whole workspace (package + core) bundles into one ~61MB binary;
|
|
12
|
+
* - runtime fs reads of package.json do NOT work inside the binary
|
|
13
|
+
* (`/$bunfs/`) — versions are embedded via static json imports;
|
|
14
|
+
* - bun:sqlite works compiled (memoryd ran live: DB created, MCP up,
|
|
15
|
+
* heartbeat, clean SIGTERM); the sqlite-vec extension path is exercised
|
|
16
|
+
* only with an embedding endpoint configured and degrades to BM25-only
|
|
17
|
+
* with a logged reason — re-checked at the P3c live smoke.
|
|
18
|
+
*
|
|
19
|
+
* Recompilation needs SOURCES: a compiled binary cannot rebuild itself from
|
|
20
|
+
* /$bunfs — `install-binary` run from the installed binary reports
|
|
21
|
+
* `skipped-compiled` (re-install goes through npx, which runs from source).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
export function isCompiledRuntime(): boolean {
|
|
28
|
+
return import.meta.url.includes("/$bunfs/");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type InstallBinaryOutcome =
|
|
32
|
+
| { action: "compiled"; outPath: string; bytes: number }
|
|
33
|
+
| { action: "skipped-compiled"; outPath: string }
|
|
34
|
+
| { action: "failed"; outPath: string; detail: string };
|
|
35
|
+
|
|
36
|
+
export function installBinary(opts: { outPath: string }): InstallBinaryOutcome {
|
|
37
|
+
const { outPath } = opts;
|
|
38
|
+
if (isCompiledRuntime()) {
|
|
39
|
+
return { action: "skipped-compiled", outPath };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const cliPath = new URL("./cli.ts", import.meta.url).pathname;
|
|
43
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
44
|
+
const tmp = path.join(
|
|
45
|
+
path.dirname(outPath),
|
|
46
|
+
`.${path.basename(outPath)}.build.tmp`,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const proc = Bun.spawnSync(
|
|
50
|
+
[process.execPath, "build", "--compile", cliPath, "--outfile", tmp],
|
|
51
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
52
|
+
);
|
|
53
|
+
if (proc.exitCode !== 0 || !fs.existsSync(tmp)) {
|
|
54
|
+
try {
|
|
55
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
56
|
+
} catch {
|
|
57
|
+
// best effort
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
action: "failed",
|
|
61
|
+
outPath,
|
|
62
|
+
detail: proc.stderr.toString().trim() || `bun build exited ${proc.exitCode}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fs.chmodSync(tmp, 0o755);
|
|
67
|
+
fs.renameSync(tmp, outPath); // atomic swap — safe over a running binary on macOS
|
|
68
|
+
return { action: "compiled", outPath, bytes: fs.statSync(outPath).size };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function removeBinary(outPath: string): "removed" | "absent" {
|
|
72
|
+
if (!fs.existsSync(outPath)) return "absent";
|
|
73
|
+
fs.unlinkSync(outPath);
|
|
74
|
+
return "removed";
|
|
75
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* iapeer-memory CLI — the package facade over core (docs/10-distribution.md).
|
|
4
|
+
*
|
|
5
|
+
* The package IS the system (ADR-009): everything live — memoryd with the
|
|
6
|
+
* MCP-http endpoint, fragment/index/doctrine rendering, frontmatter tooling,
|
|
7
|
+
* install/verify/update — enters through this binary. The claude/codex
|
|
8
|
+
* plugins are thin session sockets that call back into it.
|
|
9
|
+
*
|
|
10
|
+
* Boot order: resolve the path namespace → load the package config file
|
|
11
|
+
* (env precedence: flags > process env > config file > defaults,
|
|
12
|
+
* `config-env.ts`) → dispatch. Exit codes: 0 ok, 1 command failed /
|
|
13
|
+
* verify found problems, 2 usage error or not-yet-implemented stage.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { loadConfigFile } from "./config-env.js";
|
|
17
|
+
import { memoryPaths } from "./paths.js";
|
|
18
|
+
import { packageVersion } from "./version.js";
|
|
19
|
+
import { cmdFmUpdate } from "./commands/fm-update.js";
|
|
20
|
+
import { cmdHook } from "./commands/hook.js";
|
|
21
|
+
import { cmdInit } from "./commands/init.js";
|
|
22
|
+
import { cmdInstallBinary } from "./commands/install-binary.js";
|
|
23
|
+
import { cmdMemoryd } from "./commands/memoryd.js";
|
|
24
|
+
import { cmdMigrate } from "./commands/migrate.js";
|
|
25
|
+
import { cmdRender } from "./commands/render.js";
|
|
26
|
+
import { cmdStatus } from "./commands/status.js";
|
|
27
|
+
import { cmdUninstall } from "./commands/uninstall.js";
|
|
28
|
+
import { cmdUpdate } from "./commands/update.js";
|
|
29
|
+
import { cmdVerify } from "./commands/verify.js";
|
|
30
|
+
|
|
31
|
+
export const USAGE = `iapeer-memory — peer memory for the iapeer ecosystem
|
|
32
|
+
|
|
33
|
+
Usage: iapeer-memory <command> [options]
|
|
34
|
+
|
|
35
|
+
Commands:
|
|
36
|
+
init provision the system on this host (vault, config,
|
|
37
|
+
role peers, memoryd registration); idempotent
|
|
38
|
+
uninstall [--keep-binary] remove the system: slot declaration + binary
|
|
39
|
+
(vault and config are kept — user-owned)
|
|
40
|
+
status read-only diagnostics: verify + slot + MCP probe
|
|
41
|
+
+ inbox load
|
|
42
|
+
verify [--repair] check (and repair) the live surfaces: config,
|
|
43
|
+
memory-provider slot, memoryd heartbeat, role
|
|
44
|
+
doctrine versions
|
|
45
|
+
update [--skip-binary] update every surface (ADR-010): binary recompile,
|
|
46
|
+
templates, doctrine re-render, slot version,
|
|
47
|
+
launcher, managed memoryd restart
|
|
48
|
+
install-binary [--out P] compile the stable CLI binary (~/.local/bin) —
|
|
49
|
+
init step / repair path; needs package sources
|
|
50
|
+
fm-update [ops] FILE... structural frontmatter edits + attribution stamp
|
|
51
|
+
migrate --source DIR move harness auto-memory into the vault
|
|
52
|
+
(dry-run by default; --apply to execute)
|
|
53
|
+
render index|fragment|doctrine|guide
|
|
54
|
+
render one artifact explicitly (memoryd does this
|
|
55
|
+
continuously; render is the manual/scripted path)
|
|
56
|
+
memoryd run the daemon in the foreground (stdout = event
|
|
57
|
+
lines; supervised by a notifier watcher)
|
|
58
|
+
hook post-write|session-start
|
|
59
|
+
plugin-hook engine (the adapters' bash hooks are
|
|
60
|
+
3-line shims around these; fail-open by contract)
|
|
61
|
+
version print the package version
|
|
62
|
+
help print this help
|
|
63
|
+
|
|
64
|
+
Config file: ~/.iapeer/plugins/iapeer-memory/config.env (env format;
|
|
65
|
+
overridable via IAPEER_MEMORY_CONFIG_FILE). An explicit IAPEER_MEMORY_* in
|
|
66
|
+
the process env always wins over the file.`;
|
|
67
|
+
|
|
68
|
+
export async function main(argv: string[]): Promise<number> {
|
|
69
|
+
const [cmd, ...rest] = argv;
|
|
70
|
+
|
|
71
|
+
// The config file is the env context of every command (except pure
|
|
72
|
+
// help/version, where a broken file must not block the output).
|
|
73
|
+
if (cmd && !["help", "--help", "-h", "version", "--version", "-V"].includes(cmd)) {
|
|
74
|
+
loadConfigFile(memoryPaths().configFile);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
switch (cmd) {
|
|
78
|
+
case undefined:
|
|
79
|
+
case "help":
|
|
80
|
+
case "--help":
|
|
81
|
+
case "-h":
|
|
82
|
+
console.log(USAGE);
|
|
83
|
+
return 0;
|
|
84
|
+
case "version":
|
|
85
|
+
case "--version":
|
|
86
|
+
case "-V":
|
|
87
|
+
console.log(packageVersion());
|
|
88
|
+
return 0;
|
|
89
|
+
case "init":
|
|
90
|
+
return cmdInit(rest);
|
|
91
|
+
case "uninstall":
|
|
92
|
+
return cmdUninstall(rest);
|
|
93
|
+
case "status":
|
|
94
|
+
return cmdStatus(rest);
|
|
95
|
+
case "verify":
|
|
96
|
+
return cmdVerify(rest);
|
|
97
|
+
case "update":
|
|
98
|
+
return cmdUpdate(rest);
|
|
99
|
+
case "install-binary":
|
|
100
|
+
return cmdInstallBinary(rest);
|
|
101
|
+
case "fm-update":
|
|
102
|
+
return cmdFmUpdate(rest);
|
|
103
|
+
case "migrate":
|
|
104
|
+
return cmdMigrate(rest);
|
|
105
|
+
case "render":
|
|
106
|
+
return cmdRender(rest);
|
|
107
|
+
case "memoryd":
|
|
108
|
+
return cmdMemoryd(rest);
|
|
109
|
+
case "hook":
|
|
110
|
+
return cmdHook(rest);
|
|
111
|
+
default:
|
|
112
|
+
console.error(`iapeer-memory: unknown command: ${cmd}\n`);
|
|
113
|
+
console.error(USAGE);
|
|
114
|
+
return 2;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (import.meta.main) {
|
|
119
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
120
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `iapeer-memory fm-update` — argv glue for the core `fmUpdate` engine.
|
|
3
|
+
* The contract is fixed in the core module header (core/src/fm-update.ts):
|
|
4
|
+
*
|
|
5
|
+
* iapeer-memory fm-update [--agent NAME] [--vault PATH] [--no-stamp]
|
|
6
|
+
* [--set KEY VALUE | --unset KEY | --list-add KEY VALUE
|
|
7
|
+
* | --list-remove KEY VALUE]...
|
|
8
|
+
* FILE [FILE ...]
|
|
9
|
+
*
|
|
10
|
+
* - identity: `--agent` → PEER_PERSONALITY → IAPEER_MEMORY_AGENT_NAME
|
|
11
|
+
* (resolveAgentName, нюанс 10 — never guessed from cwd);
|
|
12
|
+
* - with no operations it is a pure attribution stamp;
|
|
13
|
+
* - taxonomy/curator-set come from the env context (config file already
|
|
14
|
+
* loaded by the CLI boot), NOT from configFromEnv — fm-update must work
|
|
15
|
+
* on explicit paths without a provisioned vault env.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
collectOps,
|
|
20
|
+
DEFAULT_CURATOR_SET,
|
|
21
|
+
fmUpdate,
|
|
22
|
+
getTaxonomy,
|
|
23
|
+
isLocaleId,
|
|
24
|
+
resolveAgentName,
|
|
25
|
+
} from "@agfpd/iapeer-memory-core";
|
|
26
|
+
|
|
27
|
+
export function cmdFmUpdate(argv: string[]): number {
|
|
28
|
+
let agent: string | null = null;
|
|
29
|
+
let vault = "";
|
|
30
|
+
let stamp = true;
|
|
31
|
+
const set: Array<[string, string]> = [];
|
|
32
|
+
const unset: string[] = [];
|
|
33
|
+
const listAdd: Array<[string, string]> = [];
|
|
34
|
+
const listRemove: Array<[string, string]> = [];
|
|
35
|
+
const files: string[] = [];
|
|
36
|
+
|
|
37
|
+
const take = (flag: string, i: number): string => {
|
|
38
|
+
const v = argv[i];
|
|
39
|
+
if (v === undefined) throw new UsageError(`${flag} requires a value`);
|
|
40
|
+
return v;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const a = argv[i];
|
|
46
|
+
switch (a) {
|
|
47
|
+
case "--agent":
|
|
48
|
+
agent = take(a, ++i);
|
|
49
|
+
break;
|
|
50
|
+
case "--vault":
|
|
51
|
+
vault = take(a, ++i);
|
|
52
|
+
break;
|
|
53
|
+
case "--no-stamp":
|
|
54
|
+
stamp = false;
|
|
55
|
+
break;
|
|
56
|
+
case "--set":
|
|
57
|
+
set.push([take(a, ++i), take(a, ++i)]);
|
|
58
|
+
break;
|
|
59
|
+
case "--unset":
|
|
60
|
+
unset.push(take(a, ++i));
|
|
61
|
+
break;
|
|
62
|
+
case "--list-add":
|
|
63
|
+
listAdd.push([take(a, ++i), take(a, ++i)]);
|
|
64
|
+
break;
|
|
65
|
+
case "--list-remove":
|
|
66
|
+
listRemove.push([take(a, ++i), take(a, ++i)]);
|
|
67
|
+
break;
|
|
68
|
+
default:
|
|
69
|
+
if (a.startsWith("--")) throw new UsageError(`unknown flag: ${a}`);
|
|
70
|
+
files.push(a);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!files.length) throw new UsageError("no files given");
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err instanceof UsageError) {
|
|
76
|
+
console.error(`iapeer-memory fm-update: ${err.message}`);
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const localeRaw = process.env.IAPEER_MEMORY_LOCALE || "en";
|
|
83
|
+
if (!isLocaleId(localeRaw)) {
|
|
84
|
+
console.error(`iapeer-memory fm-update: unknown locale "${localeRaw}"`);
|
|
85
|
+
return 2;
|
|
86
|
+
}
|
|
87
|
+
const curatorSet = (process.env.IAPEER_MEMORY_CURATOR_SET || "")
|
|
88
|
+
.split(",")
|
|
89
|
+
.map((s) => s.trim())
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
|
|
92
|
+
fmUpdate({
|
|
93
|
+
files,
|
|
94
|
+
ops: collectOps({ set, unset, listAdd, listRemove }),
|
|
95
|
+
agent: resolveAgentName(agent),
|
|
96
|
+
vault: vault || process.env.IAPEER_MEMORY_VAULT_PATH || "",
|
|
97
|
+
taxonomy: getTaxonomy(localeRaw),
|
|
98
|
+
curatorSet: curatorSet.length ? curatorSet : DEFAULT_CURATOR_SET,
|
|
99
|
+
stamp,
|
|
100
|
+
});
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
class UsageError extends Error {}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `iapeer-memory hook <event>` — the TESTABLE engine of the plugin hooks
|
|
3
|
+
* (ADR-009: the plugin is a thin session socket; its bash hooks are 3-line
|
|
4
|
+
* shims that exec this CLI). All parsing/gating logic lives here under
|
|
5
|
+
* tests — the reference's bash/python JSON juggling is deliberately not
|
|
6
|
+
* ported.
|
|
7
|
+
*
|
|
8
|
+
* hook post-write stdin: PostToolUse event JSON. Claude tools
|
|
9
|
+
* Write|Edit|MultiEdit (the codex `apply_patch`
|
|
10
|
+
* branch lands in P5 behind the live-format gate).
|
|
11
|
+
* Stamps vault-note frontmatter through the SAME
|
|
12
|
+
* core fill the fm-update path uses; a NEW note
|
|
13
|
+
* (Write) in the author's own agent-memory folder
|
|
14
|
+
* additionally emits the "team material?" reminder.
|
|
15
|
+
* Reference fact: PostToolUse plain stdout is NOT
|
|
16
|
+
* injected — the reminder goes out as
|
|
17
|
+
* hookSpecificOutput.additionalContext JSON.
|
|
18
|
+
*
|
|
19
|
+
* hook session-start SessionStart health-check (ADR-009 п.3): NEVER a
|
|
20
|
+
* content inject. Provisioned + fresh heartbeat →
|
|
21
|
+
* silent. Unprovisioned → one-line hint. Missing /
|
|
22
|
+
* stale heartbeat → one-line degraded warning
|
|
23
|
+
* (SessionStart plain stdout IS injected — reference
|
|
24
|
+
* fact) + a DEBOUNCED background kick of
|
|
25
|
+
* `verify --repair` (stamp file in state dir — many
|
|
26
|
+
* peers waking at once must not storm repairs).
|
|
27
|
+
*
|
|
28
|
+
* Both verbs are FAIL-OPEN: any internal error is appended to
|
|
29
|
+
* `<logs>/hook-errors.log` and the exit code stays 0 — a memory hook must
|
|
30
|
+
* never block the author's tool flow or session start.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_CURATOR_SET,
|
|
37
|
+
fmUpdate,
|
|
38
|
+
getTaxonomy,
|
|
39
|
+
isLocaleId,
|
|
40
|
+
resolveAgentName,
|
|
41
|
+
} from "@agfpd/iapeer-memory-core";
|
|
42
|
+
import { memoryPaths, type MemoryPaths } from "../paths.js";
|
|
43
|
+
import { DEFAULT_HEARTBEAT_STALE_MS } from "./verify.js";
|
|
44
|
+
|
|
45
|
+
/** Tools whose writes stamp frontmatter. P5 adds "apply_patch" (codex). */
|
|
46
|
+
export const POST_WRITE_TOOLS: ReadonlySet<string> = new Set([
|
|
47
|
+
"Write",
|
|
48
|
+
"Edit",
|
|
49
|
+
"MultiEdit",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** Min interval between background verify-kicks (anti-storm). */
|
|
53
|
+
export const KICK_DEBOUNCE_MS = 5 * 60_000;
|
|
54
|
+
|
|
55
|
+
// ── post-write ───────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export type PostWriteResult = {
|
|
58
|
+
stamped: boolean;
|
|
59
|
+
/** JSON string for stdout (hookSpecificOutput), or null for silence. */
|
|
60
|
+
output: string | null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function reminderText(inboxFolder: string): string {
|
|
64
|
+
return (
|
|
65
|
+
"[iapeer-memory] New note in your agent memory. Check the guide's " +
|
|
66
|
+
"canon-vs-memory filter: does any part of it belong to the team's shared " +
|
|
67
|
+
`knowledge? If yes — also drop a draft into ${inboxFolder}/ and mention ` +
|
|
68
|
+
"this note inline as [[Title]] in the draft body; the Index will link them."
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function runPostWrite(
|
|
73
|
+
eventJson: string,
|
|
74
|
+
env: Record<string, string | undefined> = process.env,
|
|
75
|
+
): PostWriteResult {
|
|
76
|
+
const silent: PostWriteResult = { stamped: false, output: null };
|
|
77
|
+
|
|
78
|
+
let event: { tool_name?: string; tool_input?: { file_path?: string } };
|
|
79
|
+
try {
|
|
80
|
+
event = JSON.parse(eventJson) as typeof event;
|
|
81
|
+
} catch {
|
|
82
|
+
return silent; // malformed event — not our problem to escalate
|
|
83
|
+
}
|
|
84
|
+
const tool = event.tool_name ?? "";
|
|
85
|
+
// Cheap tool gate FIRST (codex sends post_tool_use for EVERY tool —
|
|
86
|
+
// reference live-smoke fact; the same ordering keeps claude cheap too).
|
|
87
|
+
if (!POST_WRITE_TOOLS.has(tool)) return silent;
|
|
88
|
+
|
|
89
|
+
const filePath = event.tool_input?.file_path ?? "";
|
|
90
|
+
if (!filePath.endsWith(".md")) return silent;
|
|
91
|
+
|
|
92
|
+
const vault = env.IAPEER_MEMORY_VAULT_PATH ?? "";
|
|
93
|
+
if (!vault) return silent; // socket without a provisioned system
|
|
94
|
+
if (!filePath.startsWith(vault.endsWith(path.sep) ? vault : vault + path.sep)) {
|
|
95
|
+
return silent; // outside the vault — никогда не трогаем чужие файлы
|
|
96
|
+
}
|
|
97
|
+
if (!fs.existsSync(filePath)) return silent; // failed write — nothing to stamp
|
|
98
|
+
|
|
99
|
+
// Identity: PEER_PERSONALITY → IAPEER_MEMORY_AGENT_NAME. NO cwd guessing
|
|
100
|
+
// (нюанс 10 — deliberate divergence from the reference basename(PWD)).
|
|
101
|
+
const agent = resolveAgentName(null, env);
|
|
102
|
+
if (!agent) return silent;
|
|
103
|
+
|
|
104
|
+
const localeRaw = env.IAPEER_MEMORY_LOCALE || "en";
|
|
105
|
+
if (!isLocaleId(localeRaw)) return silent;
|
|
106
|
+
const taxonomy = getTaxonomy(localeRaw);
|
|
107
|
+
const curatorSet = (env.IAPEER_MEMORY_CURATOR_SET || "")
|
|
108
|
+
.split(",")
|
|
109
|
+
.map((s) => s.trim())
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
|
|
112
|
+
fmUpdate({
|
|
113
|
+
files: [filePath],
|
|
114
|
+
ops: [],
|
|
115
|
+
agent,
|
|
116
|
+
vault,
|
|
117
|
+
taxonomy,
|
|
118
|
+
curatorSet: curatorSet.length ? curatorSet : DEFAULT_CURATOR_SET,
|
|
119
|
+
stamp: true,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Reminder: ONLY on Write (new note) in the author's OWN memory folder —
|
|
123
|
+
// an Edit-loop on one note must not spam the context (reference semantics).
|
|
124
|
+
const ownMemoryDir =
|
|
125
|
+
path.join(vault, taxonomy.folders.agentMemory, agent) + path.sep;
|
|
126
|
+
const output =
|
|
127
|
+
tool === "Write" && filePath.startsWith(ownMemoryDir)
|
|
128
|
+
? JSON.stringify({
|
|
129
|
+
hookSpecificOutput: {
|
|
130
|
+
hookEventName: "PostToolUse",
|
|
131
|
+
additionalContext: reminderText(taxonomy.folders.inbox),
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
: null;
|
|
135
|
+
|
|
136
|
+
return { stamped: true, output };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── session-start ────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export type SessionStartResult = {
|
|
142
|
+
/** One-line context message, or null for a healthy silent start. */
|
|
143
|
+
output: string | null;
|
|
144
|
+
kicked: boolean;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export function runSessionStart(opts: {
|
|
148
|
+
paths?: MemoryPaths;
|
|
149
|
+
env?: Record<string, string | undefined>;
|
|
150
|
+
nowMs?: number;
|
|
151
|
+
staleMs?: number;
|
|
152
|
+
/** Injectable background-kicker (the CLI glue spawns verify --repair). */
|
|
153
|
+
kick?: () => void;
|
|
154
|
+
}): SessionStartResult {
|
|
155
|
+
const env = opts.env ?? process.env;
|
|
156
|
+
const paths = opts.paths ?? memoryPaths(env);
|
|
157
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
158
|
+
const staleMs = opts.staleMs ?? DEFAULT_HEARTBEAT_STALE_MS;
|
|
159
|
+
|
|
160
|
+
// Not provisioned at all (no config file AND no env context): one hint,
|
|
161
|
+
// no repair kick — there is nothing to repair yet.
|
|
162
|
+
const vault = env.IAPEER_MEMORY_VAULT_PATH ?? "";
|
|
163
|
+
if (!vault && !fs.existsSync(paths.configFile)) {
|
|
164
|
+
return {
|
|
165
|
+
output:
|
|
166
|
+
"[iapeer-memory] plugin is installed but the system is not " +
|
|
167
|
+
"provisioned on this host — run: npx @agfpd/iapeer-memory init",
|
|
168
|
+
kicked: false,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let problem: string | null = null;
|
|
173
|
+
try {
|
|
174
|
+
const ageMs = nowMs - fs.statSync(paths.heartbeatPath).mtimeMs;
|
|
175
|
+
if (ageMs > staleMs) {
|
|
176
|
+
problem = `memoryd heartbeat is stale (${Math.round(ageMs / 1000)}s old) — the daemon looks hung`;
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
problem = "memoryd is not running (no heartbeat)";
|
|
180
|
+
}
|
|
181
|
+
if (!problem) return { output: null, kicked: false };
|
|
182
|
+
|
|
183
|
+
// Debounced self-repair kick (ADR-010: the system is repaired by whichever
|
|
184
|
+
// peer is alive). The stamp file gates the storm of simultaneous wakes.
|
|
185
|
+
let kicked = false;
|
|
186
|
+
const stamp = path.join(paths.stateDir, "verify-kick.stamp");
|
|
187
|
+
let recentKick = false;
|
|
188
|
+
try {
|
|
189
|
+
recentKick = nowMs - fs.statSync(stamp).mtimeMs < KICK_DEBOUNCE_MS;
|
|
190
|
+
} catch {
|
|
191
|
+
recentKick = false;
|
|
192
|
+
}
|
|
193
|
+
if (!recentKick) {
|
|
194
|
+
try {
|
|
195
|
+
fs.mkdirSync(paths.stateDir, { recursive: true });
|
|
196
|
+
fs.writeFileSync(stamp, `${new Date(nowMs).toISOString()}\n`);
|
|
197
|
+
opts.kick?.();
|
|
198
|
+
kicked = true;
|
|
199
|
+
} catch {
|
|
200
|
+
// best effort — the warning still reaches the context
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
output:
|
|
206
|
+
`[iapeer-memory] degraded: ${problem}. ` +
|
|
207
|
+
(kicked
|
|
208
|
+
? "Kicked `verify --repair` in the background; "
|
|
209
|
+
: "Repair was kicked recently; ") +
|
|
210
|
+
"check with: iapeer-memory verify",
|
|
211
|
+
kicked,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── CLI glue ─────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
function logHookError(err: unknown): void {
|
|
218
|
+
try {
|
|
219
|
+
const dir = memoryPaths().logsDir;
|
|
220
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
221
|
+
fs.appendFileSync(
|
|
222
|
+
path.join(dir, "hook-errors.log"),
|
|
223
|
+
`${new Date().toISOString()} ${String(err)}\n`,
|
|
224
|
+
);
|
|
225
|
+
} catch {
|
|
226
|
+
// truly nowhere to report — stay silent, stay fail-open
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function cmdHook(argv: string[]): Promise<number> {
|
|
231
|
+
const [event] = argv;
|
|
232
|
+
try {
|
|
233
|
+
switch (event) {
|
|
234
|
+
case "post-write": {
|
|
235
|
+
const result = runPostWrite(await Bun.stdin.text());
|
|
236
|
+
if (result.output) console.log(result.output);
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
case "session-start": {
|
|
240
|
+
const result = runSessionStart({
|
|
241
|
+
kick: () => {
|
|
242
|
+
const cli = new URL("../cli.ts", import.meta.url).pathname;
|
|
243
|
+
const proc = Bun.spawn([process.execPath, cli, "verify", "--repair"], {
|
|
244
|
+
stdout: "ignore",
|
|
245
|
+
stderr: "ignore",
|
|
246
|
+
stdin: "ignore",
|
|
247
|
+
});
|
|
248
|
+
proc.unref();
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
if (result.output) console.log(result.output);
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
default:
|
|
255
|
+
console.error(
|
|
256
|
+
`iapeer-memory hook: unknown event: ${event ?? "(none)"} (expected post-write | session-start)`,
|
|
257
|
+
);
|
|
258
|
+
return 2;
|
|
259
|
+
}
|
|
260
|
+
} catch (err) {
|
|
261
|
+
logHookError(err);
|
|
262
|
+
return 0; // fail-open by contract
|
|
263
|
+
}
|
|
264
|
+
}
|