@agfpd/iapeer-memory-core 0.2.8 → 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 ---
@@ -255,6 +255,76 @@ export async function runVaultSearch(params: {
255
255
  return { results, pipeline };
256
256
  }
257
257
 
258
+ export type DedupMatch = { path: string; title: string; similarity: number };
259
+
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;
272
+
273
+ /**
274
+ * Dedup hint (lean §3a): given the content of a CANON note being written,
275
+ * find existing CANON notes with raw cosine similarity ≥ threshold. SEMANTIC
276
+ * by design — with embeddings disabled it returns `{enabled:false}` and the
277
+ * caller stays SILENT (a noisy BM25 overlap is worse than nothing; the author
278
+ * has memory_search). Read-only; operative/inbox matches are excluded (a
279
+ * canon note is not a duplicate of someone's operative note). Title = file
280
+ * basename (the guard sets `title` from the file name), so the caller can
281
+ * render `[[title]]`.
282
+ */
283
+ export async function runDedup(
284
+ db: CoreDb,
285
+ config: CoreConfig,
286
+ params: { content: string; threshold?: number; limit?: number; linkThreshold?: number },
287
+ ): Promise<{ enabled: boolean; matches: DedupMatch[] }> {
288
+ if (!config.embedding) return { enabled: false, matches: [] };
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;
297
+ const limit = Math.max(1, params.limit ?? 5);
298
+ const q = await embedQuery(params.content, config.embedding);
299
+ if (!q.vector) return { enabled: true, matches: [] }; // embed failed / circuit-open → silent
300
+ const raw = vectorSearch(db, q.vector, limit * 6); // headroom: canon filter + two bands
301
+ const f = config.taxonomy.folders;
302
+ const canonHeads = new Set([
303
+ f.knowledge,
304
+ f.decisions,
305
+ f.projects,
306
+ f.ideas,
307
+ f.lists,
308
+ f.archive,
309
+ ]);
310
+ const dup: DedupMatch[] = [];
311
+ const link: DedupMatch[] = [];
312
+ for (const r of raw) {
313
+ if (r.score < lower) break; // raw is cosine-descending → below the lowest band, done
314
+ const head = r.path.split("/")[0];
315
+ if (!canonHeads.has(head)) continue; // canon (+archive) only
316
+ const base = r.path.split("/").pop() ?? r.path;
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;
324
+ }
325
+ return { enabled: true, matches: [...dup, ...link] };
326
+ }
327
+
258
328
  // --- Vector search ---
259
329
  //
260
330
  // Hot path: `vec_chunks` virtual table from sqlite-vec, MATCH+ORDER BY runs
@@ -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;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Tag gate + injected dictionary projection — lean §3.
3
+ *
4
+ * In lean the author tags canon notes THEMSELVES from a controlled vocabulary
5
+ * (`99_System/Tags.md`). Two deterministic jobs (0 LLM) live here:
6
+ *
7
+ * 1. GATE (`tagGateProblems`): the guard validates a canon note's tags
8
+ * against the dictionary — an unknown tag is NOT accepted; the author is
9
+ * told to register it in the dictionary first (a deliberate step that
10
+ * kills drift: `security` vs `Безопасность`). ≥1 tag is required on canon;
11
+ * operative notes carry none. PostToolUse fires AFTER the write, so the
12
+ * «rejection» is: keep `needs_review` + teach the author to fix it next
13
+ * step (§2.3 NB).
14
+ *
15
+ * 2. PROJECTION (`renderTagsProjection`): the dictionary is now injected to
16
+ * EVERY author (pre-lean: only the Index). The injected form is a COMPACT,
17
+ * budgeted projection — names always, the boundary ONLY where the
18
+ * dictionary author wrote one (overlapping domains needing disambiguation;
19
+ * `—` marks self-evident tags → name only). Token cost is ×the whole
20
+ * fleet, so the full curator table stays the SOURCE and only this slice is
21
+ * injected (§3, §11).
22
+ *
23
+ * The dictionary is a markdown table: `| Tag | Boundary (optional) |`. Parsing
24
+ * is locale-independent (no header label hard-coded): a header row is the one
25
+ * immediately followed by the `|---|` separator.
26
+ */
27
+
28
+ export const DEFAULT_TAGS_BOUNDARY_MAXLEN = 160;
29
+
30
+ /** A `|---|`-style table separator cell. */
31
+ function isSeparatorCell(cell: string): boolean {
32
+ return /^:?-{2,}:?$/.test(cell.trim());
33
+ }
34
+
35
+ /** Split a markdown table row into trimmed cells (outer pipes dropped). */
36
+ function tableCells(line: string): string[] | null {
37
+ const t = line.trim();
38
+ if (!t.startsWith("|")) return null;
39
+ // Drop the leading and (if present) trailing pipe, then split.
40
+ const inner = t.replace(/^\|/, "").replace(/\|\s*$/, "");
41
+ return inner.split("|").map((c) => c.trim());
42
+ }
43
+
44
+ export type DictionaryEntry = { name: string; boundary: string };
45
+
46
+ /**
47
+ * Parse the dictionary table into entries (name + boundary). Skips header rows
48
+ * (a row whose NEXT line is a separator) and separator rows. Generic over any
49
+ * number of tables/sections. A `—`/`-`/empty boundary means «self-evident».
50
+ */
51
+ export function parseDictionaryEntries(dictContent: string): DictionaryEntry[] {
52
+ const lines = dictContent.split("\n");
53
+ const entries: DictionaryEntry[] = [];
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const cells = tableCells(lines[i]);
56
+ if (!cells || cells.length === 0) continue;
57
+ const name = cells[0];
58
+ if (!name || isSeparatorCell(name)) continue;
59
+ // Header row: the next non-empty line is a separator.
60
+ const nextCells = i + 1 < lines.length ? tableCells(lines[i + 1]) : null;
61
+ if (nextCells && nextCells.length && isSeparatorCell(nextCells[0])) continue;
62
+ const boundaryRaw = (cells[1] ?? "").trim();
63
+ const boundary = boundaryRaw === "—" || boundaryRaw === "-" ? "" : boundaryRaw;
64
+ entries.push({ name, boundary });
65
+ }
66
+ return entries;
67
+ }
68
+
69
+ /** Just the valid tag names (the gate's allow-set). */
70
+ export function parseDictionaryTags(dictContent: string): string[] {
71
+ return parseDictionaryEntries(dictContent).map((e) => e.name);
72
+ }
73
+
74
+ /**
75
+ * A note tag is valid when it (or its root before `/`) is in the dictionary —
76
+ * subtags (`Бизнес/Грузоперевозки`) inherit their root's membership (§3, the
77
+ * `Бизнес` boundary documents the subtag convention).
78
+ */
79
+ export function isTagAllowed(tag: string, allow: ReadonlySet<string>): boolean {
80
+ if (allow.has(tag)) return true;
81
+ const root = tag.split("/")[0];
82
+ return root !== tag && allow.has(root);
83
+ }
84
+
85
+ /** Extract tags from a frontmatter block — block-list and inline-array forms. */
86
+ export function parseNoteTags(fmBlock: string): string[] {
87
+ const lines = fmBlock.split("\n");
88
+ for (let i = 0; i < lines.length; i++) {
89
+ const m = /^tags\s*:\s*(.*)$/.exec(lines[i]);
90
+ if (!m) continue;
91
+ const inline = m[1].trim();
92
+ if (inline) {
93
+ // inline array `[A, B]` or a bare scalar.
94
+ const arr = inline.replace(/^\[/, "").replace(/\]$/, "");
95
+ return arr
96
+ .split(",")
97
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
98
+ .filter(Boolean);
99
+ }
100
+ // block-list form: following ` - item` lines.
101
+ const out: string[] = [];
102
+ for (let j = i + 1; j < lines.length; j++) {
103
+ const item = /^\s+-\s+(.*)$/.exec(lines[j]);
104
+ if (!item) break;
105
+ const v = item[1].trim().replace(/^["']|["']$/g, "");
106
+ if (v) out.push(v);
107
+ }
108
+ return out;
109
+ }
110
+ return [];
111
+ }
112
+
113
+ export type TagGateOptions = {
114
+ /** Canon requires ≥1 tag; operative/other zones do not. */
115
+ requireAtLeastOne: boolean;
116
+ /** Vault-relative dictionary path, for the teaching message. */
117
+ dictionaryRel: string;
118
+ };
119
+
120
+ /**
121
+ * Validate a note's tags against the dictionary. Returns author-facing problem
122
+ * lines (empty = clean). The guard stays SILENT when there is nothing to fix
123
+ * (§2.3); each line names a concrete fix.
124
+ */
125
+ export function tagGateProblems(
126
+ noteTags: readonly string[],
127
+ allow: ReadonlySet<string>,
128
+ opts: TagGateOptions,
129
+ ): string[] {
130
+ const problems: string[] = [];
131
+ if (opts.requireAtLeastOne && noteTags.length === 0) {
132
+ problems.push(
133
+ `canon note has no tags — add ≥1 from the dictionary (${opts.dictionaryRel}).`,
134
+ );
135
+ }
136
+ for (const tag of noteTags) {
137
+ if (!isTagAllowed(tag, allow)) {
138
+ problems.push(
139
+ `tag "${tag}" is not in the dictionary — register it in ${opts.dictionaryRel} first ` +
140
+ `(reuse an existing tag if one fits, e.g. by domain), then tag the note.`,
141
+ );
142
+ }
143
+ }
144
+ return problems;
145
+ }
146
+
147
+ /** Truncate to `max` chars on a word boundary where possible, adding `…`. */
148
+ function clip(s: string, max: number): string {
149
+ if (s.length <= max) return s;
150
+ const cut = s.slice(0, max);
151
+ const lastSpace = cut.lastIndexOf(" ");
152
+ return (lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
153
+ }
154
+
155
+ export type ProjectionOptions = {
156
+ /** Per-tag boundary character budget (×whole fleet, §11). */
157
+ boundaryMaxLen?: number;
158
+ };
159
+
160
+ /**
161
+ * Render the COMPACT injected projection of the dictionary (§3/§11): one tag
162
+ * per line, `Name` for self-evident tags, `Name — boundary` (clipped to the
163
+ * budget) for overlapping domains. No table chrome, no frontmatter — the full
164
+ * curator table stays the source. Returns "" for an empty/unparseable dict.
165
+ */
166
+ export function renderTagsProjection(dictContent: string, opts: ProjectionOptions = {}): string {
167
+ const max = opts.boundaryMaxLen ?? DEFAULT_TAGS_BOUNDARY_MAXLEN;
168
+ const entries = parseDictionaryEntries(dictContent);
169
+ if (entries.length === 0) return "";
170
+ const lines = entries.map((e) =>
171
+ e.boundary ? `${e.name} — ${clip(e.boundary, max)}` : e.name,
172
+ );
173
+ return lines.join("\n");
174
+ }
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,12 +51,22 @@ 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;
60
58
  };
61
59
 
60
+ /**
61
+ * Initial `status` token a new note of each canonical type carries — the
62
+ * guard fills `status` on the permanent branch from the FOLDER's type (lean
63
+ * mode §2.1, «начальный токен типа»). Verified against the live RU vault:
64
+ * knowledge→актуально, decision→принято, idea→новая, list→актуально,
65
+ * project→активный, agent_memory→актуально. Every token is a member of
66
+ * `statuses.active` (parity-tested).
67
+ */
68
+ export type TaxonomyInitialStatus = Record<keyof TaxonomyTypes, string>;
69
+
62
70
  export type TaxonomyStatuses = {
63
71
  /** Current/live lifecycle states — search boost ×activeBoost. */
64
72
  active: string[];
@@ -122,6 +130,9 @@ export type TaxonomyPreset = {
122
130
  subtypeOrder: string[];
123
131
  statuses: TaxonomyStatuses;
124
132
  statusTokens: TaxonomyStatusTokens;
133
+ /** Per-type initial `status` token — the guard fills the permanent branch
134
+ * from the folder's type (lean §2.1). */
135
+ initialStatus: TaxonomyInitialStatus;
125
136
  /** Phase status tokens in their project-group render order:
126
137
  * planned → active → paused → completed → cancelled. */
127
138
  phaseStatusOrder: string[];
@@ -136,8 +147,6 @@ export type TaxonomyPreset = {
136
147
  export const TAXONOMY_EN: TaxonomyPreset = {
137
148
  locale: "en",
138
149
  folders: {
139
- inbox: "00_Inbox",
140
- inboxHuman: "00_Inbox_Human",
141
150
  knowledge: "01_Knowledge",
142
151
  decisions: "02_Decisions",
143
152
  projects: "03_Projects",
@@ -169,6 +178,14 @@ export const TAXONOMY_EN: TaxonomyPreset = {
169
178
  stale: ["outdated", "superseded", "dropped", "completed", "cancelled"],
170
179
  },
171
180
  statusTokens: { draft: "draft", current: "current" },
181
+ initialStatus: {
182
+ knowledge: "current",
183
+ decision: "accepted",
184
+ idea: "new",
185
+ project: "active",
186
+ list: "current",
187
+ agentMemory: "current",
188
+ },
172
189
  phaseStatusOrder: ["planned", "active", "paused", "completed", "cancelled"],
173
190
  indexStrings: {
174
191
  header: "Vault index of notes by",
@@ -211,8 +228,6 @@ export const TAXONOMY_EN: TaxonomyPreset = {
211
228
  export const TAXONOMY_RU: TaxonomyPreset = {
212
229
  locale: "ru",
213
230
  folders: {
214
- inbox: "00_Входящие",
215
- inboxHuman: "00_Входящие_человек",
216
231
  knowledge: "01_Знания",
217
232
  decisions: "02_Решения",
218
233
  projects: "03_Проекты",
@@ -246,6 +261,14 @@ export const TAXONOMY_RU: TaxonomyPreset = {
246
261
  stale: ["устарело", "заменено", "отброшена", "завершён", "завершена", "отменена"],
247
262
  },
248
263
  statusTokens: { draft: "черновик", current: "актуально" },
264
+ initialStatus: {
265
+ knowledge: "актуально",
266
+ decision: "принято",
267
+ idea: "новая",
268
+ project: "активный",
269
+ list: "актуально",
270
+ agentMemory: "актуально",
271
+ },
249
272
  phaseStatusOrder: ["запланирована", "активная", "на паузе", "завершена", "отменена"],
250
273
  indexStrings: {
251
274
  header: "Vault-индекс заметок автора",
@@ -321,17 +344,58 @@ export function statusGroup(
321
344
  return null;
322
345
  }
323
346
 
347
+ /** True when `status` is a final/closed token — the archiving predicate
348
+ * (lean §2.2a: `isStale → move to archive`). Mirrors the search/index
349
+ * semantics (`statusGroup === "stale"`); a single source for the memoryd
350
+ * archiver and the index renderer. `на паузе`/`paused` are PENDING, not
351
+ * stale — a resumable note is never archived. */
352
+ export function isStale(taxonomy: TaxonomyPreset, status: string | null | undefined): boolean {
353
+ if (!status) return false;
354
+ return statusGroup(taxonomy, status.trim()) === "stale";
355
+ }
356
+
357
+ /**
358
+ * Folder-key → type-key pairing (lean §2.1 «helper выравнивания ключей»):
359
+ * the two maps are parallel-keyed but differ by plurality
360
+ * (`decisions`↔`decision`, `ideas`↔`idea`, `lists`↔`list`,
361
+ * `projects`↔`project`). `agentMemory` is paired too — the memory zone reuses
362
+ * the same alignment. Inbox/archive/system have no canonical type.
363
+ */
364
+ const FOLDER_TYPE_PAIRS: ReadonlyArray<[keyof TaxonomyFolders, keyof TaxonomyTypes]> = [
365
+ ["knowledge", "knowledge"],
366
+ ["decisions", "decision"],
367
+ ["projects", "project"],
368
+ ["ideas", "idea"],
369
+ ["lists", "list"],
370
+ ["agentMemory", "agentMemory"],
371
+ ];
372
+
373
+ /**
374
+ * Genre (type + initial status) declared by a vault FOLDER name — the guard
375
+ * derives `type` and the starting `status` from the note's position (lean
376
+ * §2.1: «Папка = объявление жанра»). `folderName` is the first path segment
377
+ * relative to the vault. Returns null for folders without a canonical type
378
+ * (both inboxes, archive, system) — the caller fills no type/status there.
379
+ */
380
+ export function genreForFolder(
381
+ taxonomy: TaxonomyPreset,
382
+ folderName: string,
383
+ ): { type: string; initialStatus: string } | null {
384
+ for (const [fKey, tKey] of FOLDER_TYPE_PAIRS) {
385
+ if (taxonomy.folders[fKey] === folderName) {
386
+ return { type: taxonomy.types[tKey], initialStatus: taxonomy.initialStatus[tKey] };
387
+ }
388
+ }
389
+ return null;
390
+ }
391
+
324
392
  /**
325
- * Default search-index exclusions: both inboxes (raw drafts, possibly without
326
- * frontmatter) and the system folder (templates/dictionary, not content).
327
- * 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.)
328
396
  */
329
397
  export function defaultExcludeFolders(taxonomy: TaxonomyPreset): string[] {
330
- return [
331
- taxonomy.folders.inbox,
332
- taxonomy.folders.inboxHuman,
333
- taxonomy.folders.system,
334
- ];
398
+ return [taxonomy.folders.system];
335
399
  }
336
400
 
337
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.