@agfpd/iapeer-memory-core 0.2.9 → 0.3.0

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/src/mode.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Lean §7/§7.1 — mode + per-role proactivity resolution.
3
+ *
4
+ * The toggle gates the curation OVERLAY's PROACTIVITY (notifier triggers /
5
+ * memoryd curation emits), NOT the existence of anything: the role peers
6
+ * (Index/Scriber/DreamWeaver) are ALWAYS provisioned and callable on-demand in
7
+ * either mode, and memoryd (страж detector + archive + projection render +
8
+ * /dedup) ALWAYS runs (its watcher trigger launches it — base infra, never
9
+ * gated). What the mode gates is whether the curation pipeline FIRES by itself.
10
+ *
11
+ * Default resolution: `IAPEER_MEMORY_MODE` ABSENT → `curated` — a host
12
+ * provisioned before this feature ran curated, and must never be silently
13
+ * flipped to lean (§10.3). A NEW install's `init` WRITES the key (default
14
+ * `lean`), so new hosts read lean. Per-role env overrides the preset:
15
+ * `IAPEER_MEMORY_PROACTIVE_{INDEX,SCRIBER,DREAMWEAVER}` = on|off (independent
16
+ * toggles over the preset, §7).
17
+ */
18
+
19
+ export type MemoryMode = "lean" | "curated";
20
+ export type RoleSet = { index: boolean; scriber: boolean; dreamweaver: boolean };
21
+
22
+ const TRUE_TOKENS = new Set(["1", "on", "true", "yes", "y"]);
23
+ const FALSE_TOKENS = new Set(["0", "off", "false", "no", "n"]);
24
+
25
+ function toggle(raw: string | undefined, preset: boolean): boolean {
26
+ const v = (raw ?? "").trim().toLowerCase();
27
+ if (TRUE_TOKENS.has(v)) return true;
28
+ if (FALSE_TOKENS.has(v)) return false;
29
+ return preset; // unset / unrecognised → follow the mode preset
30
+ }
31
+
32
+ export function resolveMode(env: Record<string, string | undefined> = process.env): {
33
+ mode: MemoryMode;
34
+ roles: RoleSet;
35
+ } {
36
+ const raw = (env.IAPEER_MEMORY_MODE ?? "").trim().toLowerCase();
37
+ // absent / "curated" / anything-not-"lean" → curated (legacy preservation).
38
+ const mode: MemoryMode = raw === "lean" ? "lean" : "curated";
39
+ const preset = mode === "curated";
40
+ return {
41
+ mode,
42
+ roles: {
43
+ index: toggle(env.IAPEER_MEMORY_PROACTIVE_INDEX, preset),
44
+ scriber: toggle(env.IAPEER_MEMORY_PROACTIVE_SCRIBER, preset),
45
+ dreamweaver: toggle(env.IAPEER_MEMORY_PROACTIVE_DREAMWEAVER, preset),
46
+ },
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Curation proactivity derived from the role set (§7.1 matrix). The memoryd
52
+ * WATCHER trigger is deliberately NOT here — it ALWAYS registers (it launches
53
+ * memoryd; the base needs memoryd in both modes). This is exactly what the
54
+ * toggle gates ON TOP of the always-on watcher.
55
+ */
56
+ export type CurationPlan = {
57
+ /** memoryd emits the curation event (CURATOR_TICK) — ONLY when a proactive
58
+ * receiver exists (scriber ∥ index). Full-lean → suppressed (the watcher
59
+ * then forwards nothing). */
60
+ emit: boolean;
61
+ /** The watcher's forward target, §7.1 conditional: scriber → else index →
62
+ * else null (no proactive receiver; placeholder, nothing is emitted). */
63
+ eventTarget: string | null;
64
+ /** Register the weekly dream-tick timer (→ dreamweaver) — decoupled from
65
+ * the Index (§7.1 key #2); only if dreamweaver is proactive. */
66
+ dream: boolean;
67
+ };
68
+
69
+ export function curationPlan(roles: RoleSet): CurationPlan {
70
+ const eventTarget = roles.scriber ? "scriber" : roles.index ? "index" : null;
71
+ return {
72
+ emit: eventTarget !== null,
73
+ eventTarget,
74
+ dream: roles.dreamweaver,
75
+ };
76
+ }
@@ -75,85 +75,7 @@ export function diffSnapshots(prev: VaultSnapshot, next: VaultSnapshot): string[
75
75
  return changed.sort();
76
76
  }
77
77
 
78
- export type PermanentChangedEvent = {
79
- kind: "PERMANENT_CHANGED";
80
- /** Coalesced list of changed vault-relative paths (ADR-004). */
81
- paths: string[];
82
- };
83
-
84
- /**
85
- * One detection pass: diff the current vault state against the previous
86
- * snapshot. Returns the coalesced event (or null when nothing changed)
87
- * plus the snapshot to carry into the next pass.
88
- */
89
- export function detectPermanentChanges(opts: {
90
- vault: string;
91
- taxonomy: TaxonomyPreset;
92
- prev: VaultSnapshot;
93
- }): { event: PermanentChangedEvent | null; next: VaultSnapshot } {
94
- const next = snapshotVault(opts.vault, opts.taxonomy);
95
- const paths = diffSnapshots(opts.prev, next);
96
- return {
97
- event: paths.length ? { kind: "PERMANENT_CHANGED", paths } : null,
98
- next,
99
- };
100
- }
101
-
102
- /**
103
- * Render the event as memoryd stdout signal lines (one per path — the
104
- * notifier forwards each line verbatim; the Index batches the visible
105
- * burst in one turn, and the coalescing above already bounds the burst to
106
- * one pass).
107
- */
108
- export function formatEventLines(event: PermanentChangedEvent): string[] {
109
- return event.paths.map((p) => `PERMANENT_CHANGED: ${p}`);
110
- }
111
-
112
- /**
113
- * Inbox snapshot: draft basename → smart hash (top-level `.md` only —
114
- * drafts live flat). Same smart-hash mechanics as the permanent snapshot:
115
- * service-field re-stamps are invisible, content edits are events.
116
- *
117
- * WHY hash-diff and not a name baseline (B-приёмка 10.06, boris): the
118
- * original name-set baseline made a draft INVISIBLE FOREVER once it
119
- * survived a memoryd restart, and silently broke the reject→fix→re-review
120
- * cycle (an author's fix never re-entered the pipeline). The reference
121
- * semantics «the Index catches pre-existing drafts up at its own start»
122
- * died with the inversion — the Index no longer scans the inbox; the
123
- * fail-open sweep covers pre-existing UNCHANGED drafts, the hash-diff
124
- * covers everything else.
125
- */
126
- export function snapshotFlatFolder(dir: string): VaultSnapshot {
127
- const snapshot: VaultSnapshot = new Map();
128
- let entries: fs.Dirent[];
129
- try {
130
- entries = fs.readdirSync(dir, { withFileTypes: true });
131
- } catch {
132
- return snapshot;
133
- }
134
- for (const e of entries) {
135
- if (!e.isFile() || !e.name.endsWith(".md")) continue;
136
- const h = hashFile(path.join(dir, e.name));
137
- if (h) snapshot.set(e.name, h);
138
- }
139
- return snapshot;
140
- }
141
-
142
- export function snapshotInbox(vault: string, taxonomy: TaxonomyPreset): VaultSnapshot {
143
- return snapshotFlatFolder(path.join(vault, taxonomy.folders.inbox));
144
- }
145
-
146
- /**
147
- * One inbox detection pass: new or semantically changed drafts since the
148
- * previous snapshot (deletions ignored — a placement move by the Index).
149
- * Scans the WHOLE inbox folder, independent of which fs.watch path
150
- * triggered the flush — no dependency on watch filename fidelity.
151
- */
152
- export function detectInboxChanges(opts: {
153
- vault: string;
154
- taxonomy: TaxonomyPreset;
155
- prev: VaultSnapshot;
156
- }): { names: string[]; next: VaultSnapshot } {
157
- const next = snapshotInbox(opts.vault, opts.taxonomy);
158
- return { names: diffSnapshots(opts.prev, next), next };
159
- }
78
+ // The daemon coalesces a detection pass into ONE batch event (CURATOR_TICK)
79
+ // by diffing `snapshotVault` against the carried baseline directly — see
80
+ // `runCuratorTick` in memoryd.ts. The earlier per-line event helper was
81
+ // vestigial and removed with the inbox pipeline.
package/src/search.ts CHANGED
@@ -22,7 +22,7 @@ import { escapeFtsQuery, queryTokens } from "./utils.js";
22
22
  import { agentMemoryFolderMarker, statusGroup } from "./taxonomy.js";
23
23
 
24
24
  /**
25
- * Per-step pipeline status, прокидывается наверх в response vault_search,
25
+ * Per-step pipeline status, прокидывается наверх в response memory_search,
26
26
  * чтобы агент-вызыватель видел в каком режиме отработал поиск (полный
27
27
  * пайплайн / BM25-only из-за деградации эмбеддингов / без reranker).
28
28
  *
@@ -62,7 +62,7 @@ const SNIPPET_LEAD_CHARS = 40;
62
62
  const EMPTY_NOTE_SNIPPET = "(пустой каркас — в заметке нет содержимого)";
63
63
  // How many top-degree related notes to surface per result. Was unbounded —
64
64
  // hub notes (e.g. a project Plan note) inflated payload to ~10KB on a 6-result
65
- // search. Agents can call vault_graph for the full neighborhood.
65
+ // search. Agents can call memory_related for the full neighborhood.
66
66
  const RELATED_LIMIT = 3;
67
67
 
68
68
  // --- Frontmatter status categories & ranking coefficients ---
@@ -257,7 +257,18 @@ export async function runVaultSearch(params: {
257
257
 
258
258
  export type DedupMatch = { path: string; title: string; similarity: number };
259
259
 
260
- export const DEFAULT_DEDUP_THRESHOLD = 0.82;
260
+ /** Dup band lower bound — raw cosine ≥ this ⇒ «possible duplicate» (§3a).
261
+ * 0.78 confirmed by Артур (15.06) on live TEI calibration: paraphrase
262
+ * 0.81–0.88 caught, related-but-different ~0.72 excluded; 0.82 clipped heavy
263
+ * paraphrase at 0.815. Env-overridable (`IAPEER_MEMORY_DEDUP_THRESHOLD`). */
264
+ export const DEFAULT_DEDUP_THRESHOLD = 0.78;
265
+
266
+ /** Link-hint band lower bound — cosine in [this, DEDUP_THRESHOLD) ⇒
267
+ * «semantically close, maybe link» (§3b, Артур 15.06). HONEST framing: catches
268
+ * semantically SIMILAR notes, NOT complementary ones (a real link at cosine
269
+ * ~0.54 is missed) — additive to the Index's link saturation (§8), not a
270
+ * replacement. Env-overridable. */
271
+ export const DEFAULT_LINK_HINT_THRESHOLD = 0.68;
261
272
 
262
273
  /**
263
274
  * Dedup hint (lean §3a): given the content of a CANON note being written,
@@ -272,14 +283,21 @@ export const DEFAULT_DEDUP_THRESHOLD = 0.82;
272
283
  export async function runDedup(
273
284
  db: CoreDb,
274
285
  config: CoreConfig,
275
- params: { content: string; threshold?: number; limit?: number },
286
+ params: { content: string; threshold?: number; limit?: number; linkThreshold?: number },
276
287
  ): Promise<{ enabled: boolean; matches: DedupMatch[] }> {
277
288
  if (!config.embedding) return { enabled: false, matches: [] };
278
289
  const threshold = params.threshold ?? DEFAULT_DEDUP_THRESHOLD;
290
+ // §3b: when a link-band lower bound is given, `threshold` is the DUP-band
291
+ // bound and `linkThreshold` the LINK-band bound; each band gets its OWN cap so
292
+ // a burst of ≥`limit` dup matches never starves the link band (both are
293
+ // returned, the caller re-classifies by similarity). Without it → legacy
294
+ // single-band behaviour (identical to before).
295
+ const linkThreshold = params.linkThreshold;
296
+ const lower = linkThreshold !== undefined ? Math.min(linkThreshold, threshold) : threshold;
279
297
  const limit = Math.max(1, params.limit ?? 5);
280
298
  const q = await embedQuery(params.content, config.embedding);
281
299
  if (!q.vector) return { enabled: true, matches: [] }; // embed failed / circuit-open → silent
282
- const raw = vectorSearch(db, q.vector, limit * 3); // headroom for the canon filter
300
+ const raw = vectorSearch(db, q.vector, limit * 6); // headroom: canon filter + two bands
283
301
  const f = config.taxonomy.folders;
284
302
  const canonHeads = new Set([
285
303
  f.knowledge,
@@ -289,16 +307,22 @@ export async function runDedup(
289
307
  f.lists,
290
308
  f.archive,
291
309
  ]);
292
- const matches: DedupMatch[] = [];
310
+ const dup: DedupMatch[] = [];
311
+ const link: DedupMatch[] = [];
293
312
  for (const r of raw) {
294
- if (r.score < threshold) continue;
313
+ if (r.score < lower) break; // raw is cosine-descending → below the lowest band, done
295
314
  const head = r.path.split("/")[0];
296
315
  if (!canonHeads.has(head)) continue; // canon (+archive) only
297
316
  const base = r.path.split("/").pop() ?? r.path;
298
- matches.push({ path: r.path, title: base.replace(/\.md$/, ""), similarity: r.score });
299
- if (matches.length >= limit) break;
317
+ const m: DedupMatch = { path: r.path, title: base.replace(/\.md$/, ""), similarity: r.score };
318
+ if (r.score >= threshold) {
319
+ if (dup.length < limit) dup.push(m);
320
+ } else if (linkThreshold !== undefined && r.score >= linkThreshold) {
321
+ if (link.length < limit) link.push(m);
322
+ }
323
+ if (dup.length >= limit && (linkThreshold === undefined || link.length >= limit)) break;
300
324
  }
301
- return { enabled: true, matches };
325
+ return { enabled: true, matches: [...dup, ...link] };
302
326
  }
303
327
 
304
328
  // --- Vector search ---
@@ -14,19 +14,11 @@
14
14
  * our response re-stamp echo-safe BY CONSTRUCTION: a re-stamp touches only
15
15
  * service fields, so it never re-triggers the detector or the batch diffs.
16
16
  *
17
- * ZONED rule (the humanEditPass intersection, §3a of the design):
18
- * permanent — ONLY the fresh-window case (stamp ≤ FRESH_EDIT_WINDOW_S):
19
- * that is exactly the echo-window swallow; STALE cases stay with
20
- * humanEditPass (human attribution — the Obsidian main case; a wider
21
- * rule would regress «Артур правит кураторскую заметку»);
22
- * inbox — UNCONDITIONAL: humanEditPass does not cover the agent inbox at
23
- * all, human edits there are marginal, and the curator-masquerade
24
- * (memoryd's 822-suppress) plus the silent author edit are both closed
25
- * by one branch. PRECONDITION (the 11.06 false-positive lesson, boris's
26
- * Write+Edit repro): the rule is only a discriminator because fillInbox
27
- * UPSERTS the stamp pair on every hook edit — before that fix the pair
28
- * was identically (null, null) for author drafts, «stamp unmoved» held
29
- * vacuously and every second legitimate edit re-stamped as unstamped.
17
+ * ZONED rule (the humanEditPass intersection, §3a of the design): the canon
18
+ * folders (five typed + project notes + agent memory) — ONLY the fresh-window
19
+ * case (stamp ≤ FRESH_EDIT_WINDOW_S): that is exactly the echo-window swallow;
20
+ * STALE cases stay with humanEditPass (human attribution — the Obsidian main
21
+ * case; a wider rule would regress «Артур правит кураторскую заметку»).
30
22
  *
31
23
  * Attribution token: `unstamped` — NEUTRAL on purpose (the mechanism
32
24
  * cannot tell a Bash agent from a human outside Obsidian; a false «agent»
@@ -49,7 +41,7 @@ import { smartHash } from "./smart-hash.js";
49
41
 
50
42
  export const UNSTAMPED_TOKEN = "unstamped";
51
43
 
52
- export type SilentZone = "permanent" | "inbox";
44
+ export type SilentZone = "permanent";
53
45
 
54
46
  /** The per-file baseline record (design §4): the SEMANTIC hash (the BASE
55
47
  * precondition — without it an iCloud mtime-echo inside the fresh window
@@ -87,18 +79,17 @@ export type DecideSilentInput = {
87
79
  };
88
80
 
89
81
  /**
90
- * BASE: the semantic hash MOVED ∧ the stamp pair did not move verbatim.
91
- * Zone branch: permanent → only when the standing stamp is FRESH (the
92
- * echo-window swallow); inbox unconditional. A service-only change
93
- * (hook echo, our own re-stamp) keeps the semantic hash still → never
94
- * silent; an mtime-only event keeps content identical → never silent.
82
+ * BASE: the semantic hash MOVED ∧ the stamp pair did not move verbatim,
83
+ * and only when the standing stamp is FRESH (the echo-window swallow). A
84
+ * service-only change (hook echo, our own re-stamp) keeps the semantic hash
85
+ * still never silent; an mtime-only event keeps content identical → never
86
+ * silent.
95
87
  */
96
88
  export function isSilentEdit(input: DecideSilentInput): boolean {
97
89
  if (input.prev.hash === input.curr.hash) return false; // BASE: no semantic move
98
90
  const stampUnmoved =
99
91
  input.prev.updated === input.curr.updated && input.prev.leb === input.curr.leb;
100
92
  if (!stampUnmoved) return false;
101
- if (input.zone === "inbox") return true;
102
93
  const windowS = input.freshEditWindowS ?? DEFAULT_FRESH_EDIT_WINDOW_S;
103
94
  const editAt = parseUpdated(input.curr.updated);
104
95
  return editAt !== null && (input.nowMs - editAt) / 1000 < windowS;
package/src/taxonomy.ts CHANGED
@@ -22,8 +22,6 @@ export type LocaleId = "en" | "ru";
22
22
  export type StatusGroup = "active" | "pending" | "stale";
23
23
 
24
24
  export type TaxonomyFolders = {
25
- inbox: string;
26
- inboxHuman: string;
27
25
  knowledge: string;
28
26
  decisions: string;
29
27
  projects: string;
@@ -53,7 +51,7 @@ export type TaxonomySubtypes = {
53
51
 
54
52
  /** Individual status tokens the WRITER side must emit (hook fills). */
55
53
  export type TaxonomyStatusTokens = {
56
- /** Initial status of a new inbox draft. */
54
+ /** The draft status token (pending group) author-settable WIP marker. */
57
55
  draft: string;
58
56
  /** Initial status of a new agent-memory note. */
59
57
  current: string;
@@ -149,8 +147,6 @@ export type TaxonomyPreset = {
149
147
  export const TAXONOMY_EN: TaxonomyPreset = {
150
148
  locale: "en",
151
149
  folders: {
152
- inbox: "00_Inbox",
153
- inboxHuman: "00_Inbox_Human",
154
150
  knowledge: "01_Knowledge",
155
151
  decisions: "02_Decisions",
156
152
  projects: "03_Projects",
@@ -232,8 +228,6 @@ export const TAXONOMY_EN: TaxonomyPreset = {
232
228
  export const TAXONOMY_RU: TaxonomyPreset = {
233
229
  locale: "ru",
234
230
  folders: {
235
- inbox: "00_Входящие",
236
- inboxHuman: "00_Входящие_человек",
237
231
  knowledge: "01_Знания",
238
232
  decisions: "02_Решения",
239
233
  projects: "03_Проекты",
@@ -396,16 +390,12 @@ export function genreForFolder(
396
390
  }
397
391
 
398
392
  /**
399
- * Default search-index exclusions: both inboxes (raw drafts, possibly without
400
- * frontmatter) and the system folder (templates/dictionary, not content).
401
- * The archive is NOT excludedit stays searchable with the stale boost.
393
+ * Default search-index exclusions: the system folder (templates/dictionary,
394
+ * not content). The archive is NOT excluded it stays searchable with the
395
+ * stale boost. (Inbox folders are gone authors write straight into canon.)
402
396
  */
403
397
  export function defaultExcludeFolders(taxonomy: TaxonomyPreset): string[] {
404
- return [
405
- taxonomy.folders.inbox,
406
- taxonomy.folders.inboxHuman,
407
- taxonomy.folders.system,
408
- ];
398
+ return [taxonomy.folders.system];
409
399
  }
410
400
 
411
401
  /**
package/src/utils.ts CHANGED
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  // Hash the FULL file content. Was a 4096-char prefix hash — that skipped
5
5
  // reindexing whenever an append-only note (План / Фаза / Список — a
6
6
  // first-class vault genre that grows downward) changed below the prefix
7
- // window, leaving vault_search / embeddings on the stale tail. Markdown is
7
+ // window, leaving memory_search / embeddings on the stale tail. Markdown is
8
8
  // cheap; correctness over the micro-optimisation. If profiling ever demands
9
9
  // it, gate a full hash behind size+mtime, never trust a prefix as the source
10
10
  // of truth.