@agfpd/iapeer-memory-core 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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@agfpd/iapeer-memory-core",
3
+ "version": "0.1.1",
4
+ "description": "iapeer-memory core — host-neutral TypeScript memory primitive: vault schema/taxonomy config, search engine, memoryd, context renderer, role contracts. Consumed by the @agfpd/iapeer-memory facade; version kept in lockstep by its release flow (docs/10-distribution.md).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "src",
9
+ "tsconfig.json"
10
+ ],
11
+ "exports": {
12
+ ".": "./src/index.ts"
13
+ },
14
+ "scripts": {
15
+ "test": "bun test",
16
+ "typecheck": "tsc --noEmit",
17
+ "prepublishOnly": "test -z \"$(git status --porcelain)\" || (echo 'release: working tree is dirty — commit or stash before release' >&2 && exit 1)"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^1.29.0",
24
+ "gray-matter": "^4.0.3",
25
+ "sqlite-vec": "^0.1.9",
26
+ "zod": "^4.4.3"
27
+ },
28
+ "devDependencies": {
29
+ "@types/bun": "^1.2.0",
30
+ "typescript": "^5.7.0"
31
+ }
32
+ }
package/src/config.ts ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * iapeer-memory core runtime config.
3
+ *
4
+ * Re-built from the MergeMind reference `config.ts` for the iapeer-memory
5
+ * namespace: `IAPEER_MEMORY_*` env vars, taxonomy-driven defaults (ADR-002 —
6
+ * the locale preset supplies folder names instead of hard-coded constants)
7
+ * and the `~/.iapeer/{cache,…}/iapeer-memory/` path namespace.
8
+ *
9
+ * Embedding/reranker stay pluggable and off-by-default: empty endpoint →
10
+ * BM25-only, a valid working state. Any endpoint speaking the
11
+ * OpenAI-compatible `/v1/embeddings` + TEI `/rerank` protocols works; no
12
+ * provider is hard-coded.
13
+ *
14
+ * `callerAgent`: at the MCP layer the caller identity comes from the
15
+ * `X-IAPeer-Identity` header of the http connection (ADR-012) and takes
16
+ * precedence; the env var is the fallback for CLI/programmatic consumers
17
+ * outside harness sessions.
18
+ */
19
+
20
+ import { statSync } from "node:fs";
21
+ import { isEmbeddingProvider, type EmbeddingProvider } from "./embedding.js";
22
+ import { isRerankerProvider, type RerankerProvider } from "./reranker.js";
23
+ import {
24
+ defaultExcludeFolders,
25
+ getTaxonomy,
26
+ isLocaleId,
27
+ DEFAULT_CURATOR_SET,
28
+ DEFAULT_RANKING,
29
+ type LocaleId,
30
+ type RankingConfig,
31
+ type TaxonomyPreset,
32
+ } from "./taxonomy.js";
33
+
34
+ export type CoreConfig = {
35
+ vaultPath: string;
36
+ locale: LocaleId;
37
+ taxonomy: TaxonomyPreset;
38
+ ranking: RankingConfig;
39
+ excludeFolders: string[];
40
+ /** ADR-006 curator set — personalities exempt from needs_review stamping. */
41
+ curatorSet: string[];
42
+ callerAgent: string | null;
43
+ search: {
44
+ chunkSize: number;
45
+ chunkOverlap: number;
46
+ maxResults: number;
47
+ rrfK: number;
48
+ };
49
+ index: {
50
+ dbPath: string;
51
+ fullScanOnStartup: boolean;
52
+ };
53
+ /**
54
+ * MCP-http endpoint of memoryd (ADR-012). The default port 8766 is the
55
+ * neighbour of the iapeer foundation MCP (8765) — one ecosystem block,
56
+ * easy to remember, far from the OS ephemeral ranges. `0` = ephemeral
57
+ * (tests). The adapters' `.mcp.json` must reference the SAME port.
58
+ */
59
+ mcp: {
60
+ port: number;
61
+ };
62
+ embedding: {
63
+ endpoint: string;
64
+ model: string;
65
+ dimensions: number;
66
+ batchSize: number;
67
+ apiKey: string | null;
68
+ /** Wire format (ADR-013); configFromEnv always sets it. */
69
+ provider?: EmbeddingProvider;
70
+ } | null;
71
+ reranker: {
72
+ endpoint: string;
73
+ model: string;
74
+ topK: number;
75
+ weight: number;
76
+ apiKey: string | null;
77
+ /** Wire format (ADR-013); configFromEnv always sets it. */
78
+ provider?: RerankerProvider;
79
+ } | null;
80
+ };
81
+
82
+ /**
83
+ * Keep an operator-configured LOCAL embedding/reranker host off the egress
84
+ * proxy. Empirically proven failure mode (inherited from the reference):
85
+ * the process env may carry HTTP(S)_PROXY; NO_PROXY usually excludes LAN via
86
+ * CIDR, but Bun's fetch does NOT implement CIDR matching in NO_PROXY (exact
87
+ * host / suffix only). Result: every embedding/reranker call detours to the
88
+ * proxy, gets a 500, opens the circuit breaker and search silently degrades
89
+ * to BM25. Adding the exact endpoint host to NO_PROXY is idempotent and
90
+ * monotonic — it only bypasses the explicitly configured host.
91
+ */
92
+ function ensureEndpointNotProxied(endpoint: string): void {
93
+ let host: string;
94
+ try {
95
+ host = new URL(endpoint).hostname;
96
+ } catch {
97
+ return; // broken URL — let fetch fail loudly, that's a different misconfig
98
+ }
99
+ if (!host) return;
100
+ for (const varName of ["NO_PROXY", "no_proxy"]) {
101
+ const current = process.env[varName] ?? "";
102
+ const tokens = current.split(",").map((s) => s.trim()).filter(Boolean);
103
+ if (tokens.includes(host)) continue;
104
+ tokens.push(host);
105
+ process.env[varName] = tokens.join(",");
106
+ }
107
+ }
108
+
109
+ function envString(name: string, fallback = ""): string {
110
+ const value = process.env[name];
111
+ return typeof value === "string" && value.length > 0 ? value : fallback;
112
+ }
113
+
114
+ function envNumber(name: string, fallback: number): number {
115
+ const value = process.env[name];
116
+ // "" is treated as unset — harness ${VAR} expansion in .mcp.json renders
117
+ // missing variables as the empty string, not "undefined".
118
+ if (typeof value !== "string" || value.length === 0) return fallback;
119
+ const n = Number(value);
120
+ return Number.isFinite(n) ? n : fallback;
121
+ }
122
+
123
+ function envBoolean(name: string, fallback: boolean): boolean {
124
+ const value = process.env[name];
125
+ if (typeof value !== "string" || value.length === 0) return fallback;
126
+ return value === "1" || value.toLowerCase() === "true";
127
+ }
128
+
129
+ function envStringArray(name: string, fallback: string[]): string[] {
130
+ const value = process.env[name];
131
+ if (typeof value !== "string" || value.length === 0) return fallback;
132
+ return value.split(",").map((s) => s.trim()).filter(Boolean);
133
+ }
134
+
135
+ export function configFromEnv(): CoreConfig {
136
+ const vaultPath = envString("IAPEER_MEMORY_VAULT_PATH");
137
+ if (!vaultPath) {
138
+ throw new Error(
139
+ "IAPEER_MEMORY_VAULT_PATH is not set. Run init to provision the vault, " +
140
+ "then point IAPEER_MEMORY_VAULT_PATH at its absolute path in the " +
141
+ "package config and restart.",
142
+ );
143
+ }
144
+
145
+ // Validate the vault path resolves to a directory. Without this check the
146
+ // process would start "healthy" on a typo / unmounted drive / offloaded
147
+ // iCloud root — search would return empty results and agents would assume
148
+ // an empty vault. Fail loud at startup beats silent degraded mode.
149
+ try {
150
+ const stat = statSync(vaultPath);
151
+ if (!stat.isDirectory()) {
152
+ throw new Error(
153
+ `IAPEER_MEMORY_VAULT_PATH (${vaultPath}) is not a directory.`,
154
+ );
155
+ }
156
+ } catch (err) {
157
+ const e = err as { code?: string; message?: string };
158
+ if (e.code === "ENOENT") {
159
+ throw new Error(
160
+ `IAPEER_MEMORY_VAULT_PATH (${vaultPath}) does not exist. Check that ` +
161
+ `the vault is provisioned and the path is correct (including a ` +
162
+ `possibly offloaded iCloud root).`,
163
+ );
164
+ }
165
+ throw err;
166
+ }
167
+
168
+ const rawLocale = envString("IAPEER_MEMORY_LOCALE", "en");
169
+ if (!isLocaleId(rawLocale)) {
170
+ throw new Error(
171
+ `IAPEER_MEMORY_LOCALE (${rawLocale}) is not a known locale preset ` +
172
+ `(expected "en" or "ru").`,
173
+ );
174
+ }
175
+ const taxonomy = getTaxonomy(rawLocale);
176
+
177
+ const dbPath = envString(
178
+ "IAPEER_MEMORY_DB_PATH",
179
+ `${process.env.HOME}/.iapeer/cache/iapeer-memory/index.db`,
180
+ );
181
+
182
+ const embeddingEndpoint = envString("IAPEER_MEMORY_EMBEDDING_ENDPOINT");
183
+ const rerankerEndpoint = envString("IAPEER_MEMORY_RERANKER_ENDPOINT");
184
+
185
+ // Provider enums (ADR-013). Unknown value → throw, exactly like an
186
+ // unknown locale: fail loud at startup beats a silently dead pipeline.
187
+ // Defaults preserve the inherited behaviour: embedding "openai"
188
+ // (the reference wire format), reranker "tei" when an endpoint is set.
189
+ // Reranker "none" is the explicit OFF switch: the block resolves to null
190
+ // even with an endpoint configured (the pipeline default state — no
191
+ // reranker — is simply an empty endpoint, as before).
192
+ const embeddingProviderRaw = envString("IAPEER_MEMORY_EMBEDDING_PROVIDER", "openai");
193
+ if (!isEmbeddingProvider(embeddingProviderRaw)) {
194
+ throw new Error(
195
+ `IAPEER_MEMORY_EMBEDDING_PROVIDER (${embeddingProviderRaw}) is not a known ` +
196
+ `provider (expected "tei" or "openai").`,
197
+ );
198
+ }
199
+ const rerankerProviderRaw = envString("IAPEER_MEMORY_RERANKER_PROVIDER", "tei");
200
+ if (rerankerProviderRaw !== "none" && !isRerankerProvider(rerankerProviderRaw)) {
201
+ throw new Error(
202
+ `IAPEER_MEMORY_RERANKER_PROVIDER (${rerankerProviderRaw}) is not a known ` +
203
+ `provider (expected "tei", "cohere", "nvidia", "jina" or "none").`,
204
+ );
205
+ }
206
+
207
+ // Strip egress proxying from local endpoints before the first fetch
208
+ // (Bun fetch does not honour CIDR in NO_PROXY — see ensureEndpointNotProxied).
209
+ if (embeddingEndpoint) ensureEndpointNotProxied(embeddingEndpoint);
210
+ if (rerankerEndpoint) ensureEndpointNotProxied(rerankerEndpoint);
211
+
212
+ return {
213
+ vaultPath,
214
+ locale: rawLocale,
215
+ taxonomy,
216
+ ranking: { ...DEFAULT_RANKING },
217
+ callerAgent: envString("IAPEER_MEMORY_AGENT_NAME") || null,
218
+ curatorSet: envStringArray("IAPEER_MEMORY_CURATOR_SET", [...DEFAULT_CURATOR_SET]),
219
+ excludeFolders: envStringArray(
220
+ "IAPEER_MEMORY_EXCLUDE_FOLDERS",
221
+ defaultExcludeFolders(taxonomy),
222
+ ),
223
+ search: {
224
+ chunkSize: envNumber("IAPEER_MEMORY_CHUNK_SIZE", 500),
225
+ chunkOverlap: envNumber("IAPEER_MEMORY_CHUNK_OVERLAP", 80),
226
+ maxResults: envNumber("IAPEER_MEMORY_MAX_RESULTS", 6),
227
+ rrfK: envNumber("IAPEER_MEMORY_RRF_K", 60),
228
+ },
229
+ index: {
230
+ dbPath,
231
+ fullScanOnStartup: envBoolean("IAPEER_MEMORY_FULL_SCAN_ON_STARTUP", true),
232
+ },
233
+ mcp: {
234
+ port: envNumber("IAPEER_MEMORY_MCP_PORT", 8766),
235
+ },
236
+ embedding: embeddingEndpoint
237
+ ? {
238
+ provider: embeddingProviderRaw,
239
+ endpoint: embeddingEndpoint,
240
+ model: envString("IAPEER_MEMORY_EMBEDDING_MODEL", "Qwen/Qwen3-Embedding-8B"),
241
+ dimensions: envNumber("IAPEER_MEMORY_EMBEDDING_DIMENSIONS", 4096),
242
+ batchSize: envNumber("IAPEER_MEMORY_EMBEDDING_BATCH_SIZE", 32),
243
+ apiKey: envString("IAPEER_MEMORY_EMBEDDING_API_KEY") || null,
244
+ }
245
+ : null,
246
+ reranker: rerankerEndpoint && rerankerProviderRaw !== "none"
247
+ ? {
248
+ provider: rerankerProviderRaw,
249
+ endpoint: rerankerEndpoint,
250
+ model: envString("IAPEER_MEMORY_RERANKER_MODEL", "BAAI/bge-reranker-v2-m3"),
251
+ topK: envNumber("IAPEER_MEMORY_RERANKER_TOP_K", 20),
252
+ weight: envNumber("IAPEER_MEMORY_RERANKER_WEIGHT", 0.7),
253
+ apiKey: envString("IAPEER_MEMORY_RERANKER_API_KEY") || null,
254
+ }
255
+ : null,
256
+ };
257
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * context-render — doctrine-fragment assembly for iapeer layer 5 (ADR-001).
3
+ *
4
+ * Carries over the LAYER ASSEMBLY from the reference
5
+ * `mergemind-build-context-shards.py::build_layers` (order of layers, the
6
+ * Index branch) and DROPS the entire shard mechanics (est_tokens /
7
+ * pack_shards / write_shards / k-N markers / MAX_SHARDS padding) — per-hook
8
+ * limits do not exist on the system-prompt path; the fragment is delivered
9
+ * whole by the iapeer merge.
10
+ *
11
+ * Changes against the reference assembly, all sanctioned:
12
+ * - the writer guide is NOT a per-peer layer anymore — it is the HOST-WIDE
13
+ * fragment (`~/.iapeer/fragments/iapeer-memory.md`), written by the
14
+ * package at install/update, one copy for the whole fleet;
15
+ * - the L2/L3 user-override layers are dropped (нюанс 1): user additions
16
+ * ride iapeer's own layer 4 (any *.md in the `.iapeer/` roots);
17
+ * - `capText` is kept as a parity utility (it capped L2/L3 in the
18
+ * reference) but the current assembly has nothing left to cap.
19
+ *
20
+ * Layer-5 writer contract (iapeer 0.2.8, docs/05-delivery-and-context.md):
21
+ * fragment files live in `<root>/.iapeer/fragments/<stem>.md`, the stem is
22
+ * `iapeer-memory.md`, and writes MUST be atomic (temp + rename in the same
23
+ * directory) — the fragment is re-read on every cold-wake and may race a
24
+ * peer waking up.
25
+ */
26
+
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ import crypto from "node:crypto";
30
+
31
+ export const FRAGMENT_STEM = "iapeer-memory.md";
32
+
33
+ export type ContextLayer = [title: string, body: string];
34
+
35
+ function readFileOrEmpty(filePath: string): string {
36
+ try {
37
+ return fs.readFileSync(filePath, "utf-8");
38
+ } catch {
39
+ return "";
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Trim text to maxChars at a line boundary, appending a marker when cut.
45
+ * Parity utility (capped the user-override layers in the reference);
46
+ * the marker template takes `{label}` and `{max}` placeholders.
47
+ */
48
+ export function capText(
49
+ text: string,
50
+ maxChars: number,
51
+ label: string,
52
+ template = "\n\n_[{label} truncated: over the {max} character limit. Shorten the document.]_",
53
+ ): string {
54
+ if (text.length <= maxChars) return text;
55
+ let cut = text.lastIndexOf("\n", maxChars);
56
+ if (cut < Math.floor(maxChars / 2)) cut = maxChars;
57
+ const trimmed = text.slice(0, cut).replace(/\s+$/, "");
58
+ return trimmed + template.replace("{label}", label).replace("{max}", String(maxChars));
59
+ }
60
+
61
+ export type FragmentEnv = {
62
+ /** Peer personality (resolved by the caller — нюанс 10). */
63
+ agent: string;
64
+ /** Personality of the Index curator (its branch has a different layout). */
65
+ indexAgent?: string;
66
+ paths: {
67
+ vault: string;
68
+ db?: string;
69
+ config?: string;
70
+ state: string;
71
+ cache: string;
72
+ logs?: string;
73
+ };
74
+ /** Rendered author index file (capped variant), absolute path. */
75
+ authorIndexPath: string;
76
+ /** Tags-dictionary mirror, absolute path (Index branch only). */
77
+ tagsDictionaryPath?: string;
78
+ };
79
+
80
+ /**
81
+ * Assemble the per-peer fragment layers in the reference build_layers
82
+ * order. Author branch: paths → author index. Index branch: paths → tags
83
+ * dictionary → own index (the curator gets no writer guide — its contract
84
+ * is the role doctrine; the guide arrives host-wide by layer mechanics).
85
+ * Missing/empty sources are skipped gracefully.
86
+ */
87
+ export function buildLayers(env: FragmentEnv): ContextLayer[] {
88
+ const layers: ContextLayer[] = [];
89
+
90
+ const p = env.paths;
91
+ const pathsBlock = [
92
+ "<iapeer-memory-paths>",
93
+ `vault: ${p.vault ?? ""}`,
94
+ `db: ${p.db ?? ""}`,
95
+ `config: ${p.config ?? ""}`,
96
+ `state: ${p.state ?? ""}`,
97
+ `cache: ${p.cache ?? ""}`,
98
+ `logs: ${p.logs ?? ""}`,
99
+ "</iapeer-memory-paths>",
100
+ ].join("\n");
101
+ layers.push(["iapeer-memory paths", pathsBlock]);
102
+
103
+ const isIndex = env.agent === (env.indexAgent ?? "index");
104
+
105
+ if (isIndex && env.tagsDictionaryPath) {
106
+ const tags = readFileOrEmpty(env.tagsDictionaryPath);
107
+ if (tags.trim()) layers.push([path.basename(env.tagsDictionaryPath), tags]);
108
+ }
109
+
110
+ const idx = readFileOrEmpty(env.authorIndexPath);
111
+ if (idx.trim()) layers.push([path.basename(env.authorIndexPath), idx]);
112
+
113
+ return layers;
114
+ }
115
+
116
+ /** Render layers into the fragment text: `## title` + body per layer. */
117
+ export function renderFragmentText(layers: ContextLayer[]): string {
118
+ const parts: string[] = [];
119
+ for (const [title, body] of layers) {
120
+ parts.push(`## ${title}\n${body.replace(/\s+$/, "")}`);
121
+ }
122
+ return parts.join("\n\n") + (parts.length ? "\n" : "");
123
+ }
124
+
125
+ /**
126
+ * Atomic fragment write — temp + rename IN THE SAME DIRECTORY (layer-5
127
+ * writer contract). Creates the fragments directory when missing.
128
+ */
129
+ export function writeFragmentAtomic(
130
+ fragmentsDir: string,
131
+ text: string,
132
+ stem: string = FRAGMENT_STEM,
133
+ ): string {
134
+ // 0700 — aligned with the iapeer scaffold's fragments/ mode (author's
135
+ // review note, stage 7): peer doctrine material is owner-only.
136
+ fs.mkdirSync(fragmentsDir, { recursive: true, mode: 0o700 });
137
+ const target = path.join(fragmentsDir, stem);
138
+ const tmp = path.join(
139
+ fragmentsDir,
140
+ `.${stem}.${crypto.randomBytes(6).toString("hex")}.tmp`,
141
+ );
142
+ try {
143
+ fs.writeFileSync(tmp, text, "utf-8");
144
+ fs.renameSync(tmp, target);
145
+ } catch (err) {
146
+ try {
147
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
148
+ } catch {
149
+ // best effort
150
+ }
151
+ throw err;
152
+ }
153
+ return target;
154
+ }
155
+
156
+ /** `<peerCwd>/.iapeer/fragments/` — the per-peer fragment directory. */
157
+ export function peerFragmentsDir(peerCwd: string): string {
158
+ return path.join(peerCwd, ".iapeer", "fragments");
159
+ }
160
+
161
+ /**
162
+ * Render and atomically write the per-peer fragment for one peer.
163
+ * Returns the written path.
164
+ */
165
+ export function renderPeerFragment(opts: {
166
+ peerCwd: string;
167
+ env: FragmentEnv;
168
+ }): string {
169
+ const text = renderFragmentText(buildLayers(opts.env));
170
+ return writeFragmentAtomic(peerFragmentsDir(opts.peerCwd), text);
171
+ }
172
+
173
+ /**
174
+ * Write the HOST-WIDE guide fragment (`<homeIapeerDir>/fragments/`).
175
+ * The guide content is a package runtime artifact; this function writes
176
+ * whatever it is given. CAUTION: pointing homeIapeerDir at the production
177
+ * `~/.iapeer` reaches EVERY peer of the fleet on their next wakes — the
178
+ * fleet rollout of the guide is a separately sanctioned release step.
179
+ */
180
+ export function writeHostWideGuideFragment(
181
+ homeIapeerDir: string,
182
+ guideText: string,
183
+ ): string {
184
+ return writeFragmentAtomic(path.join(homeIapeerDir, "fragments"), guideText);
185
+ }