@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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `iapeer-memory verify [--repair]` — idempotent self-check of the live
|
|
3
|
+
* surfaces (ADR-010). Cheap by design: any peer's SessionStart health-check
|
|
4
|
+
* may kick it; the system is repaired by whichever peer is alive, never by
|
|
5
|
+
* the Index or the human.
|
|
6
|
+
*
|
|
7
|
+
* Checks (P1 scope — each lights up as its stage lands):
|
|
8
|
+
* 1. config — the package env context resolves (vault exists);
|
|
9
|
+
* 2. memoryd heartbeat — fresh within the staleness threshold;
|
|
10
|
+
* 3. notifier watcher — registration alive [skip until P3 init];
|
|
11
|
+
* 4. role doctrines — rendered version == package version (ADR-010
|
|
12
|
+
* marker); `--repair` re-renders from the package templates.
|
|
13
|
+
*
|
|
14
|
+
* The roles manifest (`<state>/roles.json`, written by init) names the
|
|
15
|
+
* roles: `{ "roles": [{ "role", "peerCwd", "template" }] }`. Absent
|
|
16
|
+
* manifest = init has not run — the doctrine check is skipped, not failed.
|
|
17
|
+
*
|
|
18
|
+
* Exit code: 0 — no failures; 1 — at least one check failed (after repair).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
configFromEnv,
|
|
25
|
+
renderDoctrine,
|
|
26
|
+
renderedVersion,
|
|
27
|
+
} from "@agfpd/iapeer-memory-core";
|
|
28
|
+
import { memoryPaths, type MemoryPaths } from "../paths.js";
|
|
29
|
+
import { readRolesManifest } from "../roles.js";
|
|
30
|
+
import { readSlot, writeSlot, SLOT_PROVIDER } from "../slot.js";
|
|
31
|
+
import { packageVersion } from "../version.js";
|
|
32
|
+
import {
|
|
33
|
+
readWatcherTrigger,
|
|
34
|
+
registerWatcher,
|
|
35
|
+
writeLauncherScript,
|
|
36
|
+
WATCHER_TRIGGER_ID,
|
|
37
|
+
} from "../watcher.js";
|
|
38
|
+
|
|
39
|
+
/** Heartbeat default is 30s (core memoryd) — 4 missed beats = stale. */
|
|
40
|
+
export const DEFAULT_HEARTBEAT_STALE_MS = 120_000;
|
|
41
|
+
|
|
42
|
+
export type CheckStatus = "ok" | "fail" | "skip" | "repaired";
|
|
43
|
+
export type CheckResult = { name: string; status: CheckStatus; detail: string };
|
|
44
|
+
|
|
45
|
+
export type VerifyOptions = {
|
|
46
|
+
repair?: boolean;
|
|
47
|
+
paths?: MemoryPaths;
|
|
48
|
+
version?: string;
|
|
49
|
+
staleMs?: number;
|
|
50
|
+
/** Injectable for tests. */
|
|
51
|
+
nowMs?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type RolesManifest = {
|
|
55
|
+
roles: Array<{ role: string; peerCwd: string; template: string }>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
|
|
59
|
+
const repair = opts.repair ?? false;
|
|
60
|
+
const paths = opts.paths ?? memoryPaths();
|
|
61
|
+
const version = opts.version ?? packageVersion();
|
|
62
|
+
const staleMs = opts.staleMs ?? DEFAULT_HEARTBEAT_STALE_MS;
|
|
63
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
64
|
+
const results: CheckResult[] = [];
|
|
65
|
+
|
|
66
|
+
// 1. config / env context
|
|
67
|
+
let configOk = false;
|
|
68
|
+
try {
|
|
69
|
+
const config = configFromEnv();
|
|
70
|
+
configOk = true;
|
|
71
|
+
results.push({
|
|
72
|
+
name: "config",
|
|
73
|
+
status: "ok",
|
|
74
|
+
detail: `vault ${config.vaultPath} (locale ${config.locale})`,
|
|
75
|
+
});
|
|
76
|
+
} catch (err) {
|
|
77
|
+
results.push({
|
|
78
|
+
name: "config",
|
|
79
|
+
status: "fail",
|
|
80
|
+
detail: `${(err as Error).message} — provision via init (P3) or fill ${paths.configFile}`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 1b. memory-provider slot (iapeer memory-slot contract): a provisioned
|
|
85
|
+
// host must declare the slot; a FOREIGN slot is never repaired over.
|
|
86
|
+
if (!configOk) {
|
|
87
|
+
results.push({
|
|
88
|
+
name: "memory-slot",
|
|
89
|
+
status: "skip",
|
|
90
|
+
detail: "not provisioned (config check failed)",
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
const slot = readSlot(paths.slotPath);
|
|
94
|
+
if (slot && slot.provider !== SLOT_PROVIDER) {
|
|
95
|
+
results.push({
|
|
96
|
+
name: "memory-slot",
|
|
97
|
+
status: "fail",
|
|
98
|
+
detail: `slot held by foreign provider "${slot.provider}" — refusing to touch (uninstall it first)`,
|
|
99
|
+
});
|
|
100
|
+
} else if (slot && slot.version === version) {
|
|
101
|
+
results.push({ name: "memory-slot", status: "ok", detail: `declared v${slot.version}` });
|
|
102
|
+
} else {
|
|
103
|
+
const problem = slot
|
|
104
|
+
? `slot declares v${slot.version}, package is v${version}`
|
|
105
|
+
: `slot declaration missing at ${paths.slotPath}`;
|
|
106
|
+
if (!repair) {
|
|
107
|
+
results.push({ name: "memory-slot", status: "fail", detail: problem });
|
|
108
|
+
} else {
|
|
109
|
+
const w = writeSlot({
|
|
110
|
+
slotPath: paths.slotPath,
|
|
111
|
+
version,
|
|
112
|
+
heartbeat: paths.heartbeatPath,
|
|
113
|
+
});
|
|
114
|
+
results.push(
|
|
115
|
+
w.action === "refused-foreign"
|
|
116
|
+
? { name: "memory-slot", status: "fail", detail: `${problem}; repair refused — foreign slot` }
|
|
117
|
+
: { name: "memory-slot", status: "repaired", detail: `${problem} — re-declared v${version}` },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 2. memoryd heartbeat
|
|
124
|
+
try {
|
|
125
|
+
const stat = fs.statSync(paths.heartbeatPath);
|
|
126
|
+
const ageMs = nowMs - stat.mtimeMs;
|
|
127
|
+
if (ageMs > staleMs) {
|
|
128
|
+
results.push({
|
|
129
|
+
name: "memoryd-heartbeat",
|
|
130
|
+
status: "fail",
|
|
131
|
+
detail:
|
|
132
|
+
`stale (${Math.round(ageMs / 1000)}s old, threshold ${Math.round(staleMs / 1000)}s)` +
|
|
133
|
+
(repair
|
|
134
|
+
? " — restart is the notifier watcher's job (registration lands in P3); manual: iapeer-memory memoryd"
|
|
135
|
+
: ""),
|
|
136
|
+
});
|
|
137
|
+
} else {
|
|
138
|
+
results.push({
|
|
139
|
+
name: "memoryd-heartbeat",
|
|
140
|
+
status: "ok",
|
|
141
|
+
detail: `fresh (${Math.round(ageMs / 1000)}s old)`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
results.push({
|
|
146
|
+
name: "memoryd-heartbeat",
|
|
147
|
+
status: "fail",
|
|
148
|
+
detail: `no heartbeat at ${paths.heartbeatPath} — memoryd not running?`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. notifier watcher registration. The durable trigger lives in the
|
|
153
|
+
// REGISTRANT's peer profile (canonical storage contract — notifier-runtime
|
|
154
|
+
// fact, topic memoryd-watcher): registrant = index, whose cwd we know
|
|
155
|
+
// from the roles manifest. Re-registration is ASYNC (the watcher session
|
|
156
|
+
// writes the profile on message receipt) — repair reports "sent", a
|
|
157
|
+
// re-run confirms.
|
|
158
|
+
{
|
|
159
|
+
const manifest = readRolesManifest(paths.rolesManifestPath);
|
|
160
|
+
const indexEntry = manifest?.roles.find((r) => r.role === "index") ?? null;
|
|
161
|
+
if (!indexEntry) {
|
|
162
|
+
results.push({
|
|
163
|
+
name: "notifier-watcher",
|
|
164
|
+
status: "skip",
|
|
165
|
+
detail: "roles manifest has no index peer — init has not run",
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
const trigger = readWatcherTrigger({ registrantCwd: indexEntry.peerCwd });
|
|
169
|
+
const scriptOk = trigger?.script === paths.launcherPath;
|
|
170
|
+
if (trigger && scriptOk) {
|
|
171
|
+
results.push({
|
|
172
|
+
name: "notifier-watcher",
|
|
173
|
+
status: "ok",
|
|
174
|
+
detail: `trigger ${WATCHER_TRIGGER_ID} in index profile → target ${trigger.target ?? "?"}`,
|
|
175
|
+
});
|
|
176
|
+
} else {
|
|
177
|
+
const problem = trigger
|
|
178
|
+
? `trigger script is ${trigger.script}, expected ${paths.launcherPath}`
|
|
179
|
+
: `no ${WATCHER_TRIGGER_ID} trigger in ${indexEntry.peerCwd}/.iapeer/peer-profile.json`;
|
|
180
|
+
if (!repair) {
|
|
181
|
+
results.push({ name: "notifier-watcher", status: "fail", detail: problem });
|
|
182
|
+
} else {
|
|
183
|
+
writeLauncherScript({
|
|
184
|
+
launcherPath: paths.launcherPath,
|
|
185
|
+
binaryPath: paths.binaryPath,
|
|
186
|
+
});
|
|
187
|
+
const sent = registerWatcher({ launcherPath: paths.launcherPath });
|
|
188
|
+
results.push(
|
|
189
|
+
sent.ok
|
|
190
|
+
? {
|
|
191
|
+
name: "notifier-watcher",
|
|
192
|
+
status: "repaired",
|
|
193
|
+
detail: `${problem} — re-registration sent (async; re-run verify to confirm)`,
|
|
194
|
+
}
|
|
195
|
+
: {
|
|
196
|
+
name: "notifier-watcher",
|
|
197
|
+
status: "fail",
|
|
198
|
+
detail: `${problem}; re-registration failed — ${sent.detail}`,
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 4. role doctrine versions (ADR-010 marker)
|
|
207
|
+
let manifestRaw: string | null = null;
|
|
208
|
+
try {
|
|
209
|
+
manifestRaw = fs.readFileSync(paths.rolesManifestPath, "utf-8");
|
|
210
|
+
} catch {
|
|
211
|
+
manifestRaw = null;
|
|
212
|
+
}
|
|
213
|
+
if (manifestRaw === null) {
|
|
214
|
+
results.push({
|
|
215
|
+
name: "role-doctrines",
|
|
216
|
+
status: "skip",
|
|
217
|
+
detail: `roles manifest absent (${paths.rolesManifestPath}) — init has not run`,
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
let manifest: RolesManifest | null = null;
|
|
221
|
+
try {
|
|
222
|
+
const parsed = JSON.parse(manifestRaw) as RolesManifest;
|
|
223
|
+
if (!Array.isArray(parsed.roles)) throw new Error("no roles array");
|
|
224
|
+
manifest = parsed;
|
|
225
|
+
} catch (err) {
|
|
226
|
+
results.push({
|
|
227
|
+
name: "role-doctrines",
|
|
228
|
+
status: "fail",
|
|
229
|
+
detail: `roles manifest unreadable: ${(err as Error).message}`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
for (const entry of manifest?.roles ?? []) {
|
|
233
|
+
const name = `doctrine[${entry.role}]`;
|
|
234
|
+
const target = path.join(entry.peerCwd, ".iapeer", "IAPEER.md");
|
|
235
|
+
let current: string | null = null;
|
|
236
|
+
try {
|
|
237
|
+
current = fs.readFileSync(target, "utf-8");
|
|
238
|
+
} catch {
|
|
239
|
+
current = null;
|
|
240
|
+
}
|
|
241
|
+
const rendered = current === null ? null : renderedVersion(current);
|
|
242
|
+
if (current !== null && rendered === version) {
|
|
243
|
+
results.push({ name, status: "ok", detail: `v${rendered}` });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const problem =
|
|
247
|
+
current === null
|
|
248
|
+
? `doctrine missing at ${target}`
|
|
249
|
+
: rendered === null
|
|
250
|
+
? "no version marker in rendered doctrine"
|
|
251
|
+
: `v${rendered} != package v${version}`;
|
|
252
|
+
if (!repair) {
|
|
253
|
+
results.push({ name, status: "fail", detail: problem });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const outcome = renderDoctrine({
|
|
257
|
+
templatePath: entry.template,
|
|
258
|
+
peerCwd: entry.peerCwd,
|
|
259
|
+
version,
|
|
260
|
+
});
|
|
261
|
+
if (outcome.action === "missing-template") {
|
|
262
|
+
results.push({
|
|
263
|
+
name,
|
|
264
|
+
status: "fail",
|
|
265
|
+
detail: `${problem}; repair failed — template missing at ${entry.template}`,
|
|
266
|
+
});
|
|
267
|
+
} else {
|
|
268
|
+
results.push({
|
|
269
|
+
name,
|
|
270
|
+
status: "repaired",
|
|
271
|
+
detail: `${problem} — re-rendered to v${version}`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return results;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function cmdVerify(argv: string[]): number {
|
|
281
|
+
let repair = false;
|
|
282
|
+
for (const a of argv) {
|
|
283
|
+
if (a === "--repair") repair = true;
|
|
284
|
+
else {
|
|
285
|
+
console.error(`iapeer-memory verify: unknown flag: ${a}`);
|
|
286
|
+
return 2;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const results = runVerify({ repair });
|
|
291
|
+
const width = Math.max(...results.map((r) => r.name.length));
|
|
292
|
+
for (const r of results) {
|
|
293
|
+
const mark =
|
|
294
|
+
r.status === "ok" ? "ok " :
|
|
295
|
+
r.status === "repaired" ? "repaired" :
|
|
296
|
+
r.status === "skip" ? "skip " : "FAIL ";
|
|
297
|
+
console.log(`${mark} ${r.name.padEnd(width)} ${r.detail}`);
|
|
298
|
+
}
|
|
299
|
+
return results.some((r) => r.status === "fail") ? 1 : 0;
|
|
300
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package config loader — env-format file (`KEY=VALUE`), applied into
|
|
3
|
+
* `process.env` with CONVENTIONAL precedence:
|
|
4
|
+
*
|
|
5
|
+
* CLI flags > process env > config file > built-in defaults
|
|
6
|
+
*
|
|
7
|
+
* i.e. a key already present (non-empty) in the process env is NOT
|
|
8
|
+
* overwritten by the file. NB: this deliberately diverges from the
|
|
9
|
+
* MergeMind bootstrap, whose `set -a; source config.env` ran AFTER the
|
|
10
|
+
* parent env and silently overrode it (documented as a legacy pitfall);
|
|
11
|
+
* here an operator's explicit `IAPEER_MEMORY_*=` always wins.
|
|
12
|
+
*
|
|
13
|
+
* Accepted lines: blank, `# comment`, `KEY=VALUE`, `export KEY=VALUE`.
|
|
14
|
+
* A VALUE wrapped in matching single or double quotes is unwrapped (one
|
|
15
|
+
* level, no escape processing — this is a config file, not a shell).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
|
|
20
|
+
const LINE_RE = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
|
|
21
|
+
|
|
22
|
+
export function parseEnvFile(text: string): Record<string, string> {
|
|
23
|
+
const out: Record<string, string> = {};
|
|
24
|
+
for (const rawLine of text.split("\n")) {
|
|
25
|
+
const line = rawLine.trim();
|
|
26
|
+
if (!line || line.startsWith("#")) continue;
|
|
27
|
+
const m = LINE_RE.exec(line);
|
|
28
|
+
if (!m) continue; // not KEY=VALUE — ignored, never a parse failure
|
|
29
|
+
let value = m[2].trim();
|
|
30
|
+
if (
|
|
31
|
+
value.length >= 2 &&
|
|
32
|
+
(value[0] === '"' || value[0] === "'") &&
|
|
33
|
+
value.endsWith(value[0])
|
|
34
|
+
) {
|
|
35
|
+
value = value.slice(1, -1);
|
|
36
|
+
}
|
|
37
|
+
out[m[1]] = value;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type LoadResult = {
|
|
43
|
+
/** File absent — a valid state (defaults apply), never an error. */
|
|
44
|
+
missing: boolean;
|
|
45
|
+
/** Keys applied into the env. */
|
|
46
|
+
applied: string[];
|
|
47
|
+
/** Keys present in the file but already set in the env (env wins). */
|
|
48
|
+
shadowed: string[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function loadConfigFile(
|
|
52
|
+
filePath: string,
|
|
53
|
+
env: Record<string, string | undefined> = process.env,
|
|
54
|
+
): LoadResult {
|
|
55
|
+
let text: string;
|
|
56
|
+
try {
|
|
57
|
+
text = fs.readFileSync(filePath, "utf-8");
|
|
58
|
+
} catch {
|
|
59
|
+
return { missing: true, applied: [], shadowed: [] };
|
|
60
|
+
}
|
|
61
|
+
const parsed = parseEnvFile(text);
|
|
62
|
+
const applied: string[] = [];
|
|
63
|
+
const shadowed: string[] = [];
|
|
64
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
65
|
+
const existing = env[key];
|
|
66
|
+
if (typeof existing === "string" && existing.length > 0) {
|
|
67
|
+
shadowed.push(key);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
env[key] = value;
|
|
71
|
+
applied.push(key);
|
|
72
|
+
}
|
|
73
|
+
return { missing: false, applied, shadowed };
|
|
74
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side path namespace of the package: `~/.iapeer/{state,cache,logs,
|
|
3
|
+
* plugins}/iapeer-memory/` (convention — docs/09-iapeer-integration.md §2).
|
|
4
|
+
*
|
|
5
|
+
* Every root is individually overridable via env (the same keys are valid
|
|
6
|
+
* `config.env` entries — docs/08 §package config):
|
|
7
|
+
*
|
|
8
|
+
* - `IAPEER_MEMORY_CONFIG_FILE` — package config (env format); default
|
|
9
|
+
* `~/.iapeer/plugins/iapeer-memory/config.env` (the precedent set by the
|
|
10
|
+
* MergeMind plugin config location);
|
|
11
|
+
* - `IAPEER_MEMORY_STATE_DIR` — author indexes, heartbeat, detect-hash
|
|
12
|
+
* state, roles manifest;
|
|
13
|
+
* - `IAPEER_MEMORY_CACHE_DIR` — SQLite index, tags-dictionary mirror;
|
|
14
|
+
* - `IAPEER_MEMORY_LOGS_DIR` — log files;
|
|
15
|
+
* - `IAPEER_MEMORY_DB_PATH` — SQLite file itself (core config reads the
|
|
16
|
+
* same key; the default here mirrors core's `<cacheDir>/index.db`).
|
|
17
|
+
*
|
|
18
|
+
* Derived file names are FIXED relative to those roots — one source of
|
|
19
|
+
* truth for every command (memoryd writes the heartbeat exactly where
|
|
20
|
+
* verify reads it, by construction).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
export type MemoryPaths = {
|
|
27
|
+
configFile: string;
|
|
28
|
+
stateDir: string;
|
|
29
|
+
cacheDir: string;
|
|
30
|
+
logsDir: string;
|
|
31
|
+
dbPath: string;
|
|
32
|
+
heartbeatPath: string;
|
|
33
|
+
hashStatePath: string;
|
|
34
|
+
tagsMirrorPath: string;
|
|
35
|
+
/** Roles manifest (written by init, read by verify/render): role → peerCwd/template. */
|
|
36
|
+
rolesManifestPath: string;
|
|
37
|
+
/** Rendered author indexes (`<agent>-vault-index.md` + `-full` variant). */
|
|
38
|
+
indexesDir: string;
|
|
39
|
+
/** memoryd pid file (written by the CLI memoryd command; uninstall stops by it). */
|
|
40
|
+
pidPath: string;
|
|
41
|
+
/**
|
|
42
|
+
* Memory-provider slot declaration (iapeer memory-slot contract, FINAL):
|
|
43
|
+
* `~/.iapeer/memory-provider.json` in the STORAGE ROOT (next to the
|
|
44
|
+
* registry) — written/removed by our init/uninstall, only READ by the core.
|
|
45
|
+
*/
|
|
46
|
+
slotPath: string;
|
|
47
|
+
/** Stable compiled CLI binary (hooks/watcher rely on this path). */
|
|
48
|
+
binaryPath: string;
|
|
49
|
+
/** Materialised package-owned templates (roles, guide) — see templates/index.ts. */
|
|
50
|
+
templatesDir: string;
|
|
51
|
+
/** memoryd launcher — the notifier watcher's script (wraps the stable binary). */
|
|
52
|
+
launcherPath: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function memoryPaths(
|
|
56
|
+
env: Record<string, string | undefined> = process.env,
|
|
57
|
+
): MemoryPaths {
|
|
58
|
+
const home = env.HOME || os.homedir();
|
|
59
|
+
// IAPEER_ROOT — the ecosystem's ONE storage-root override (fact: iapeer
|
|
60
|
+
// core constants.ts; its own sandbox tests relocate ~/.iapeer with it).
|
|
61
|
+
// Respecting it keeps our slot/state co-located with the core's registry
|
|
62
|
+
// in sandboxes and tests alike.
|
|
63
|
+
const iapeerDir = env.IAPEER_ROOT || path.join(home, ".iapeer");
|
|
64
|
+
const stateDir =
|
|
65
|
+
env.IAPEER_MEMORY_STATE_DIR || path.join(iapeerDir, "state", "iapeer-memory");
|
|
66
|
+
const cacheDir =
|
|
67
|
+
env.IAPEER_MEMORY_CACHE_DIR || path.join(iapeerDir, "cache", "iapeer-memory");
|
|
68
|
+
const logsDir =
|
|
69
|
+
env.IAPEER_MEMORY_LOGS_DIR || path.join(iapeerDir, "logs", "iapeer-memory");
|
|
70
|
+
const configFile =
|
|
71
|
+
env.IAPEER_MEMORY_CONFIG_FILE ||
|
|
72
|
+
path.join(iapeerDir, "plugins", "iapeer-memory", "config.env");
|
|
73
|
+
return {
|
|
74
|
+
configFile,
|
|
75
|
+
stateDir,
|
|
76
|
+
cacheDir,
|
|
77
|
+
logsDir,
|
|
78
|
+
dbPath: env.IAPEER_MEMORY_DB_PATH || path.join(cacheDir, "index.db"),
|
|
79
|
+
heartbeatPath: path.join(stateDir, "memoryd.heartbeat"),
|
|
80
|
+
hashStatePath: path.join(stateDir, "memoryd.hashes.json"),
|
|
81
|
+
tagsMirrorPath: path.join(cacheDir, "tags-dictionary.md"),
|
|
82
|
+
rolesManifestPath: path.join(stateDir, "roles.json"),
|
|
83
|
+
indexesDir: path.join(stateDir, "indexes"),
|
|
84
|
+
pidPath: path.join(stateDir, "memoryd.pid"),
|
|
85
|
+
slotPath: path.join(iapeerDir, "memory-provider.json"),
|
|
86
|
+
binaryPath:
|
|
87
|
+
env.IAPEER_MEMORY_BINARY_PATH || path.join(home, ".local", "bin", "iapeer-memory"),
|
|
88
|
+
templatesDir: path.join(path.dirname(configFile), "templates"),
|
|
89
|
+
launcherPath: path.join(path.dirname(configFile), "memoryd-launcher.sh"),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Rendered author index file. The basename becomes the section title inside
|
|
95
|
+
* the layer-5 fragment (context-render uses `path.basename`) — the
|
|
96
|
+
* `<agent>-vault-index.md` form keeps the visible title parity with the
|
|
97
|
+
* reference shards.
|
|
98
|
+
*/
|
|
99
|
+
export function authorIndexPath(paths: MemoryPaths, agent: string): string {
|
|
100
|
+
return path.join(paths.indexesDir, `${agent}-vault-index.md`);
|
|
101
|
+
}
|
package/src/provision.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host provisioning primitives of init (P3a slice): the vault skeleton and
|
|
3
|
+
* the package config. Both IDEMPOTENT and USER-RESPECTING: existing files
|
|
4
|
+
* are never overwritten (the config and the 99_System seeds become
|
|
5
|
+
* operator-owned the moment they exist — docs/10 «пользователь правит,
|
|
6
|
+
* пакет не перезаписывает»).
|
|
7
|
+
*
|
|
8
|
+
* The 99_System seeds are generated from the taxonomy preset in code (one
|
|
9
|
+
* source of truth with the folder/status tokens) — they are SEEDS, not
|
|
10
|
+
* templates of ours to maintain: the human/Index grow them afterwards.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import type { LocaleId, TaxonomyPreset } from "@agfpd/iapeer-memory-core";
|
|
16
|
+
|
|
17
|
+
export type ProvisionResult = {
|
|
18
|
+
createdDirs: string[];
|
|
19
|
+
createdFiles: string[];
|
|
20
|
+
kept: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** 4-field minimal frontmatter — the human's editor template (docs/01). */
|
|
24
|
+
export function draftTemplateContent(taxonomy: TaxonomyPreset): string {
|
|
25
|
+
return [
|
|
26
|
+
"---",
|
|
27
|
+
"title: {{title}}",
|
|
28
|
+
`status: ${taxonomy.statusTokens.draft}`,
|
|
29
|
+
"created: {{date}}",
|
|
30
|
+
"author: {{author}}",
|
|
31
|
+
"---",
|
|
32
|
+
"",
|
|
33
|
+
"",
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Project Overview template seed (ADR-014): the editor template for the
|
|
39
|
+
* human/Index when starting a project group. Carries the `dir:` field —
|
|
40
|
+
* the source of truth for the project's working directory.
|
|
41
|
+
*/
|
|
42
|
+
export function overviewTemplateContent(taxonomy: TaxonomyPreset): string {
|
|
43
|
+
const ru = taxonomy.locale === "ru";
|
|
44
|
+
const name = ru ? "{{имя}}" : "{{name}}";
|
|
45
|
+
return [
|
|
46
|
+
"---",
|
|
47
|
+
`title: ${taxonomy.projectFiles.overviewPrefix}${name}`,
|
|
48
|
+
"type: " + (ru ? "проект" : "project"),
|
|
49
|
+
"tags:",
|
|
50
|
+
ru ? " - {{Доменный_Тег}}" : " - {{Domain_Tag}}",
|
|
51
|
+
"status: " + (ru ? "активный" : "active"),
|
|
52
|
+
"created: {{date}}",
|
|
53
|
+
ru ? "author: {{maintainer латиницей}}" : "author: {{maintainer}}",
|
|
54
|
+
ru
|
|
55
|
+
? "dir: {{абсолютный или ~-относительный путь рабочей папки проекта — источник правды}}"
|
|
56
|
+
: "dir: {{absolute or ~-relative path to the project's working directory — the source of truth}}",
|
|
57
|
+
"---",
|
|
58
|
+
"",
|
|
59
|
+
`# ${taxonomy.projectFiles.overviewPrefix}${name}`,
|
|
60
|
+
"",
|
|
61
|
+
ru
|
|
62
|
+
? "Что за проект, цель, текущее состояние верхнего уровня."
|
|
63
|
+
: "What the project is, its goal, the current top-level state.",
|
|
64
|
+
"",
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Empty tags dictionary seed — the Index grows it (docs/07). */
|
|
69
|
+
export function tagsDictionaryContent(taxonomy: TaxonomyPreset): string {
|
|
70
|
+
const ru = taxonomy.locale === "ru";
|
|
71
|
+
return [
|
|
72
|
+
ru ? "## Доменные теги" : "## Domain tags",
|
|
73
|
+
"",
|
|
74
|
+
ru
|
|
75
|
+
? "<!-- Канонический список top-level доменных тегов. Ведёт Index; новый домен — строка в таблице. Граница обязательна для пересекающихся доменов. -->"
|
|
76
|
+
: "<!-- The canonical list of top-level domain tags. Curated by the Index; a new domain = a new table row. The boundary phrase is mandatory for overlapping domains. -->",
|
|
77
|
+
"",
|
|
78
|
+
ru ? "| Тег | Граница — про что (опционально) |" : "| Tag | Boundary — what it covers (optional) |",
|
|
79
|
+
"|---|---|",
|
|
80
|
+
"",
|
|
81
|
+
].join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create the vault folder skeleton + 99_System seeds. Returns what was
|
|
86
|
+
* created vs kept; never deletes or overwrites anything.
|
|
87
|
+
*/
|
|
88
|
+
export function provisionVault(opts: {
|
|
89
|
+
vaultPath: string;
|
|
90
|
+
taxonomy: TaxonomyPreset;
|
|
91
|
+
}): ProvisionResult {
|
|
92
|
+
const { vaultPath, taxonomy } = opts;
|
|
93
|
+
const createdDirs: string[] = [];
|
|
94
|
+
const createdFiles: string[] = [];
|
|
95
|
+
const kept: string[] = [];
|
|
96
|
+
|
|
97
|
+
for (const folder of Object.values(taxonomy.folders)) {
|
|
98
|
+
const dir = path.join(vaultPath, folder);
|
|
99
|
+
if (fs.existsSync(dir)) {
|
|
100
|
+
kept.push(folder);
|
|
101
|
+
} else {
|
|
102
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
103
|
+
createdDirs.push(folder);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The Overview template lives next to the draft template (same Templates/
|
|
108
|
+
// subfolder, provision-level name — not a taxonomy token: only the seeds
|
|
109
|
+
// know it, nothing parses it back).
|
|
110
|
+
const overviewSeedRel = path.join(
|
|
111
|
+
path.dirname(taxonomy.systemFiles.draftTemplate),
|
|
112
|
+
`${taxonomy.projectFiles.overviewPrefix.trim()}.md`,
|
|
113
|
+
);
|
|
114
|
+
const seeds: Array<[rel: string, content: string]> = [
|
|
115
|
+
[
|
|
116
|
+
path.join(taxonomy.folders.system, taxonomy.systemFiles.draftTemplate),
|
|
117
|
+
draftTemplateContent(taxonomy),
|
|
118
|
+
],
|
|
119
|
+
[
|
|
120
|
+
path.join(taxonomy.folders.system, taxonomy.systemFiles.tagsDictionary),
|
|
121
|
+
tagsDictionaryContent(taxonomy),
|
|
122
|
+
],
|
|
123
|
+
[path.join(taxonomy.folders.system, overviewSeedRel), overviewTemplateContent(taxonomy)],
|
|
124
|
+
];
|
|
125
|
+
for (const [rel, content] of seeds) {
|
|
126
|
+
const file = path.join(vaultPath, rel);
|
|
127
|
+
if (fs.existsSync(file)) {
|
|
128
|
+
kept.push(rel);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
132
|
+
fs.writeFileSync(file, content, "utf-8");
|
|
133
|
+
createdFiles.push(rel);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { createdDirs, createdFiles, kept };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type ConfigContentOptions = {
|
|
140
|
+
vaultPath: string;
|
|
141
|
+
locale: LocaleId;
|
|
142
|
+
human?: string | null;
|
|
143
|
+
embeddingEndpoint?: string | null;
|
|
144
|
+
rerankerEndpoint?: string | null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** The default package config — operator-owned once written. */
|
|
148
|
+
export function defaultConfigContent(opts: ConfigContentOptions): string {
|
|
149
|
+
return [
|
|
150
|
+
"# iapeer-memory package config (env format).",
|
|
151
|
+
"# Precedence: CLI flags > process env > this file > built-in defaults.",
|
|
152
|
+
"# This file is operator-owned: the package writes it ONCE at init and",
|
|
153
|
+
"# never overwrites it (verify/update leave it alone).",
|
|
154
|
+
"",
|
|
155
|
+
`IAPEER_MEMORY_VAULT_PATH=${opts.vaultPath}`,
|
|
156
|
+
`IAPEER_MEMORY_LOCALE=${opts.locale}`,
|
|
157
|
+
opts.human
|
|
158
|
+
? `IAPEER_MEMORY_HUMAN_NAME=${opts.human}`
|
|
159
|
+
: "# IAPEER_MEMORY_HUMAN_NAME=",
|
|
160
|
+
"",
|
|
161
|
+
"# MCP endpoint of memoryd (ADR-012). 8766 = iapeer-MCP neighbour.",
|
|
162
|
+
"# IAPEER_MEMORY_MCP_PORT=8766",
|
|
163
|
+
"",
|
|
164
|
+
"# Search providers (ADR-013). Empty endpoints = BM25-only, a valid state.",
|
|
165
|
+
opts.embeddingEndpoint
|
|
166
|
+
? `IAPEER_MEMORY_EMBEDDING_ENDPOINT=${opts.embeddingEndpoint}`
|
|
167
|
+
: "# IAPEER_MEMORY_EMBEDDING_ENDPOINT=",
|
|
168
|
+
"# IAPEER_MEMORY_EMBEDDING_PROVIDER=openai",
|
|
169
|
+
"# IAPEER_MEMORY_EMBEDDING_MODEL=Qwen/Qwen3-Embedding-8B",
|
|
170
|
+
opts.rerankerEndpoint
|
|
171
|
+
? `IAPEER_MEMORY_RERANKER_ENDPOINT=${opts.rerankerEndpoint}`
|
|
172
|
+
: "# IAPEER_MEMORY_RERANKER_ENDPOINT=",
|
|
173
|
+
"# IAPEER_MEMORY_RERANKER_PROVIDER=tei",
|
|
174
|
+
"",
|
|
175
|
+
"# Curator personalities exempt from needs_review stamping (ADR-006).",
|
|
176
|
+
"# IAPEER_MEMORY_CURATOR_SET=index,copywriter,dreamweaver",
|
|
177
|
+
"",
|
|
178
|
+
].join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function writeDefaultConfig(
|
|
182
|
+
opts: ConfigContentOptions & { configFile: string },
|
|
183
|
+
): "written" | "exists" {
|
|
184
|
+
if (fs.existsSync(opts.configFile)) return "exists";
|
|
185
|
+
fs.mkdirSync(path.dirname(opts.configFile), { recursive: true });
|
|
186
|
+
fs.writeFileSync(opts.configFile, defaultConfigContent(opts), "utf-8");
|
|
187
|
+
return "written";
|
|
188
|
+
}
|