@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.
@@ -10,15 +10,15 @@
10
10
  * hash persistence) arrives with the memoryd stage; everything
11
11
  * load-bearing (the anti-loop decision core) lives here, pure.
12
12
  *
13
- * PERMANENT mode (the five canonical folders):
13
+ * Canon folders (the five typed + project notes) — the human writes there
14
+ * directly from an external editor:
14
15
  * - `last_edited_by == human` AND `updated` fresh → echo of our own
15
16
  * write — skip;
16
17
  * - `last_edited_by` is an agent AND fresh → the post-write hook just
17
18
  * ran — skip (the hook's zone);
18
- * - otherwise → an external-editor edit: stamp
19
- * `last_edited_by: <human>` + `updated` + `needs_review: true`.
20
- *
21
- * HUMAN-INBOX mode: anti-loop as above, then the idempotent 4-field fill.
19
+ * - otherwise → an external-editor edit: the shared `fillPermanentFull`
20
+ * completes a bare-body note and stamps `last_edited_by: <human>` +
21
+ * `updated` + `needs_review: true`.
22
22
  *
23
23
  * `freshEditWindowS` is CONFIG (the stage-7 review note): default 90s —
24
24
  * the reference VALUE IS 90 (bumped from the historical 30: second-
@@ -32,6 +32,12 @@
32
32
  import crypto from "node:crypto";
33
33
  import path from "node:path";
34
34
  import type { TaxonomyPreset } from "./taxonomy.js";
35
+ import {
36
+ fillPermanentFull,
37
+ stripEmptyArrays,
38
+ normalizeAllScalars,
39
+ normalizeLinksBlock,
40
+ } from "./frontmatter-fill.js";
35
41
 
36
42
  export const DEFAULT_FRESH_EDIT_WINDOW_S = 90;
37
43
 
@@ -84,9 +90,11 @@ export function setIfMissing(fm: string, key: string, value: string): string {
84
90
  return `${fm}${tail}${key}: ${value}\n`;
85
91
  }
86
92
 
87
- export type HumanEditZone = "permanent" | "human-inbox";
93
+ export type HumanEditZone = "permanent";
88
94
 
89
- /** Zone of a file for the detector: permanent / human-inbox / null. */
95
+ /** Zone of a file for the detector: permanent (the typed canon folders) /
96
+ * null. The human writes straight into canon from Obsidian — the страж
97
+ * completes a bare-body note via fillPermanentFull. */
90
98
  export function getZone(
91
99
  filepath: string,
92
100
  vault: string,
@@ -95,7 +103,6 @@ export function getZone(
95
103
  const rel = path.relative(vault, filepath);
96
104
  const first = rel.split(path.sep)[0];
97
105
  const f = taxonomy.folders;
98
- if (first === f.inboxHuman) return "human-inbox";
99
106
  if (
100
107
  first === f.knowledge ||
101
108
  first === f.decisions ||
@@ -116,6 +123,11 @@ export type DecideUpdateInput = {
116
123
  birthtimeMs: number;
117
124
  mtimeMs: number;
118
125
  basename: string;
126
+ /** Absolute file path — the permanent branch derives the folder's genre
127
+ * (type/status) from it via the shared `fillPermanentFull` (lean §2.1). */
128
+ path: string;
129
+ /** Vault root — folder resolution for the genre lookup. */
130
+ vault: string;
119
131
  lastHash: string | null;
120
132
  taxonomy: TaxonomyPreset;
121
133
  /** Config (default DEFAULT_FRESH_EDIT_WINDOW_S). */
@@ -147,12 +159,14 @@ export function decideUpdate(input: DecideUpdateInput): DecideUpdateResult {
147
159
  if (fmMatch) {
148
160
  fmBlock = fmMatch[1];
149
161
  body = input.content.slice(fmMatch[0].length);
150
- } else if (input.zone === "human-inbox") {
162
+ } else {
163
+ // Bare body (no frontmatter) — both zones BUILD it now. In lean the guard
164
+ // must complete a human's bare canon note, not skip it (§2.2: «голое тело
165
+ // человека страж должен ДОСТРОИТЬ»; the pre-lean «permanent-no-frontmatter
166
+ // → skip» is removed — canon frontmatter is the guard's job now, not the
167
+ // Index's on placement).
151
168
  fmBlock = "";
152
169
  body = input.content;
153
- } else {
154
- // PERMANENT without frontmatter — not our zone.
155
- return { action: "skip", recordHash: null, reason: "permanent-no-frontmatter" };
156
170
  }
157
171
 
158
172
  const lebMatch = /^last_edited_by\s*:\s*(.+?)\s*$/m.exec(fmBlock);
@@ -172,42 +186,43 @@ export function decideUpdate(input: DecideUpdateInput): DecideUpdateResult {
172
186
  return { action: "skip", recordHash: currentHash, reason: "echo-agent" };
173
187
  }
174
188
 
175
- // Otherwise — an external-editor edit (or a new human-inbox file).
189
+ // Otherwise — an external-editor edit of a canon note. The SHARED guard
190
+ // fill (mandate §2: identical to the hook path). Existing notes: stamp-only
191
+ // no-op on the constants; a human's bare-body canon note: full frontmatter
192
+ // (title/type-from-folder/status/created/author). `created` ← birthtime
193
+ // (file creation, not edit time).
176
194
  const nowStamp = formatStamp(new Date(input.nowMs));
177
- let newFm = fmBlock;
178
-
179
- if (input.zone === "human-inbox") {
180
- const titleDefault = input.basename.endsWith(".md")
181
- ? input.basename.slice(0, -3)
182
- : input.basename;
183
- // `created` — file creation date. birthtime is the real creation on
184
- // APFS; mtime creeps with edits. birthtime 0 (non-APFS) → mtime.
185
- const createdSource =
186
- input.birthtimeMs > 0 ? new Date(input.birthtimeMs) : new Date(input.mtimeMs);
187
- const createdDate = createdSource.toISOString().slice(0, 10);
188
-
189
- newFm = setIfMissing(newFm, "title", titleDefault);
190
- newFm = setIfMissing(newFm, "status", input.taxonomy.statusTokens.draft);
191
- newFm = setIfMissing(newFm, "created", createdDate);
192
- newFm = setIfMissing(newFm, "author", input.human);
193
- newFm = setIfMissing(newFm, "needs_review", "true");
194
- } else {
195
- newFm = upsertField(newFm, "last_edited_by", input.human);
196
- newFm = upsertField(newFm, "updated", nowStamp);
197
- newFm = upsertField(newFm, "needs_review", "true");
198
- }
195
+ const createdSource =
196
+ input.birthtimeMs > 0 ? new Date(input.birthtimeMs) : new Date(input.mtimeMs);
197
+ // LOCAL date (symmetric with the hook path's localDateIso) — `.toISOString()`
198
+ // is UTC and would shift `created` by a day for early-local-time creations.
199
+ const createdDate = `${createdSource.getFullYear()}-${pad(createdSource.getMonth() + 1)}-${pad(createdSource.getDate())}`;
200
+ let newFm = fillPermanentFull(fmBlock, {
201
+ path: input.path,
202
+ agent: input.human,
203
+ vault: input.vault,
204
+ today: createdDate,
205
+ nowStamp,
206
+ ctx: { taxonomy: input.taxonomy },
207
+ });
199
208
 
200
209
  if (newFm === fmBlock) {
201
210
  return { action: "skip", recordHash: currentHash, reason: "noop" };
202
211
  }
203
212
 
213
+ // Mirror the hook path's normalization (mandate §2: the страж is IDENTICAL on
214
+ // both paths) — YAML-safety on every scalar + ## Связи structural validity, so
215
+ // a human's colon-bearing scalar never produces unparseable YAML that silently
216
+ // drops the note from the index.
217
+ newFm = normalizeAllScalars(stripEmptyArrays(newFm));
218
+ const newBody = normalizeLinksBlock(body, input.taxonomy);
204
219
  const fmTail = newFm.endsWith("\n") ? "" : "\n";
205
- const bodyPrefix = body.startsWith("\n") ? "" : "\n";
206
- const newContent = `---\n${newFm}${fmTail}---${bodyPrefix}${body}`;
220
+ const bodyPrefix = newBody.startsWith("\n") ? "" : "\n";
221
+ const newContent = `---\n${newFm}${fmTail}---${bodyPrefix}${newBody}`;
207
222
  return {
208
223
  action: "write",
209
224
  newContent,
210
225
  recordHash: sha256(newContent),
211
- reason: input.zone === "human-inbox" ? "fill" : "stamp",
226
+ reason: "stamp",
212
227
  };
213
228
  }
@@ -8,7 +8,7 @@
8
8
  * - **taxonomy/ranking config instead of constants** (ADR-002/011): folder
9
9
  * names, type/subtype tokens, status boost GROUPS and coefficients come
10
10
  * from the same config the search pipeline uses — bucket synchronisation
11
- * with `vault_search` holds BY CONSTRUCTION (one source), where the
11
+ * with `memory_search` holds BY CONSTRUCTION (one source), where the
12
12
  * reference kept two hand-synchronised constant sets.
13
13
  * - **personality is a parameter**: identity resolution (PEER_PERSONALITY
14
14
  * first) happens at the call level, never inside the renderer.
package/src/index.ts CHANGED
@@ -20,17 +20,69 @@ export {
20
20
  getTaxonomy,
21
21
  isLocaleId,
22
22
  defaultExcludeFolders,
23
+ genreForFolder,
24
+ isStale,
25
+ statusGroup,
23
26
  DEFAULT_CURATOR_SET,
24
27
  DEFAULT_RANKING,
25
28
  type LocaleId,
26
29
  type RankingConfig,
27
30
  type TaxonomyPreset,
31
+ type TaxonomyInitialStatus,
28
32
  } from "./taxonomy.js";
29
33
 
30
34
  // frontmatter: post-write fill + structural fm-update (CLI contract in module header)
31
- export { processFile, resolveAgentName, splitFrontmatter, type ProcessOptions } from "./frontmatter-fill.js";
35
+ export {
36
+ processFile,
37
+ resolveAgentName,
38
+ splitFrontmatter,
39
+ resolveZone,
40
+ type ProcessOptions,
41
+ type Zone,
42
+ } from "./frontmatter-fill.js";
32
43
  export { fmUpdate, collectOps, yamlSafeScalar, type FmUpdateOptions, type Op } from "./fm-update.js";
33
44
 
45
+ // mode + per-role proactivity (lean §7/§7.1)
46
+ export {
47
+ resolveMode,
48
+ curationPlan,
49
+ type MemoryMode,
50
+ type RoleSet,
51
+ type CurationPlan,
52
+ } from "./mode.js";
53
+
54
+ // dedup + link-hint bands (lean §3a/§3b)
55
+ export {
56
+ runDedup,
57
+ DEFAULT_DEDUP_THRESHOLD,
58
+ DEFAULT_LINK_HINT_THRESHOLD,
59
+ type DedupMatch,
60
+ } from "./search.js";
61
+
62
+ // deterministic archiving (lean §2.2a)
63
+ export {
64
+ isArchivableZone,
65
+ statusOf,
66
+ shouldArchive,
67
+ archiveTargetRel,
68
+ } from "./archive.js";
69
+ export { snapshotVault } from "./permanent-detect.js";
70
+
71
+ // tag gate + injected dictionary projection (lean §3)
72
+ export { tagsDictionarySourceRel } from "./tags-mirror.js";
73
+ export {
74
+ parseDictionaryEntries,
75
+ parseDictionaryTags,
76
+ isTagAllowed,
77
+ parseNoteTags,
78
+ tagGateProblems,
79
+ renderTagsProjection,
80
+ DEFAULT_TAGS_BOUNDARY_MAXLEN,
81
+ type DictionaryEntry,
82
+ type TagGateOptions,
83
+ type ProjectionOptions,
84
+ } from "./tags-gate.js";
85
+
34
86
  // author index rendering
35
87
  export { regenerateVaultIndex, fullIndexPathFor, type RenderContext } from "./index-render.js";
36
88
 
package/src/indexer.ts CHANGED
@@ -70,7 +70,7 @@ export function addTitlePath(
70
70
  * bare basename resolves only when exactly one note has it — never the last
71
71
  * indexed one. Unresolvable links are NOT silently dropped (Audit #5): they
72
72
  * move to `unresolved_links` with a reason (`missing` | `ambiguous`) so the
73
- * vault_map / nightly health-check can see vault rot. The map carries ALL
73
+ * memory_map / nightly health-check can see vault rot. The map carries ALL
74
74
  * indexed files including unchanged ones (Audit #1), so a link to a note that
75
75
  * simply wasn't re-parsed this run still resolves instead of being dropped.
76
76
  *
package/src/mcp-tools.ts CHANGED
@@ -19,7 +19,7 @@ import { agentMemoryFolderMarker } from "./taxonomy.js";
19
19
  // синхронный fetch с iCloud, без таймаута Node event loop замораживается
20
20
  // на минуты. 30 секунд — большой запас, на нормальной сети iCloud
21
21
  // укладывается за 5-15 секунд. При срабатывании пишем warn в stderr и
22
- // возвращаем not-found, чтобы caller мог упасть на vault_search fallback.
22
+ // возвращаем not-found, чтобы caller мог упасть на memory_search fallback.
23
23
  const READ_TIMEOUT_MS = 30000;
24
24
 
25
25
  function isAbortError(err: unknown): boolean {
@@ -28,7 +28,7 @@ function isAbortError(err: unknown): boolean {
28
28
  return e.name === "AbortError" || e.code === "ABORT_ERR";
29
29
  }
30
30
 
31
- // ---- vault_search ----
31
+ // ---- memory_search ----
32
32
 
33
33
  export async function runSearch(
34
34
  db: CoreDb,
@@ -47,12 +47,12 @@ export async function runSearch(
47
47
  /**
48
48
  * Public MCP tool surface (ADR-008): exactly three read-only tools.
49
49
  * `vault_read` is deliberately NOT part of the surface — in-session reading
50
- * is the harness's native Read (after vault_search the path is known), and
51
- * backlinks are covered by vault_graph(depth=1, incoming). `runRead` below
50
+ * is the harness's native Read (after memory_search the path is known), and
51
+ * backlinks are covered by memory_related(depth=1, incoming). `runRead` below
52
52
  * stays a LIBRARY function of core — for memoryd, the Index runtime, CLI and
53
53
  * programmatic consumers outside harness sessions.
54
54
  */
55
- export const MCP_TOOL_SURFACE = ["vault_search", "vault_graph", "vault_map"] as const;
55
+ export const MCP_TOOL_SURFACE = ["memory_search", "memory_related", "memory_map"] as const;
56
56
 
57
57
  // ---- vault_read — library read function (NOT on the MCP surface, ADR-008) ----
58
58
 
@@ -60,7 +60,7 @@ export const MCP_TOOL_SURFACE = ["vault_search", "vault_graph", "vault_map"] as
60
60
  * Validate and resolve a user-supplied vault path before any disk access.
61
61
  *
62
62
  * vault_read is reachable from any agent that loads the reference plugin,
63
- * including ones whose context can be poisoned by inbox drafts (prompt
63
+ * including ones whose context can be poisoned by untrusted note content (prompt
64
64
  * injection). Without containment, an attacker-controlled `path` like
65
65
  * `../../.ssh/id_rsa`, `../../.mergemind/env`, or `/etc/passwd` would
66
66
  * exfiltrate arbitrary files the MCP process can read.
@@ -110,8 +110,8 @@ function validateVaultPath(
110
110
  throw new Error("Path must not contain empty, '.' or '..' segments");
111
111
  }
112
112
 
113
- // Respect excludeFolders even for direct disk reads. Inbox drafts and
114
- // system folders are intentionally hidden from search — leaking them
113
+ // Respect excludeFolders even for direct disk reads. The system folder is
114
+ // intentionally hidden from search — leaking it
115
115
  // through vault_read would defeat the privacy contract excludeFolders
116
116
  // is supposed to provide. Same "Document not found" wording as the
117
117
  // not-in-index branch so we don't reveal whether the path exists.
@@ -145,8 +145,8 @@ export async function runRead(
145
145
  const meta = getDocumentMeta(db, docPath);
146
146
 
147
147
  // Fallback path: the requested document is not in the index (typically
148
- // because it lives in an excluded folder like `99_System/` or `00_Inbox/`,
149
- // or the watcher hasn't picked it up yet). Read it directly from disk and
148
+ // because it lives in an excluded folder like `99_System/`, or the watcher
149
+ // hasn't picked it up yet). Read it directly from disk and
150
150
  // parse it on the fly — without backlinks, since those depend on the index.
151
151
  if (!meta) {
152
152
  let text: string;
@@ -234,7 +234,7 @@ export async function runRead(
234
234
  };
235
235
  }
236
236
 
237
- // ---- vault_graph ----
237
+ // ---- memory_related ----
238
238
 
239
239
  // Oneway-фильтр графа: backlinks из `06_Оперативка_агентов/` **не**
240
240
  // показываются при запросе графа канонической заметки — граф референса не
@@ -257,7 +257,11 @@ export function runGraph(
257
257
  const centerMeta = getDocumentMeta(db, docPath);
258
258
 
259
259
  if (!centerMeta) {
260
- return { found: false, error: `Document not found: ${docPath}` };
260
+ return {
261
+ found: false,
262
+ error: `Document not found: ${docPath}`,
263
+ hint: "If you don't have the exact path, find the note first with memory_search, then pass its `path` here.",
264
+ };
261
265
  }
262
266
 
263
267
  type Node = {
@@ -354,7 +358,7 @@ export function runGraph(
354
358
  };
355
359
  }
356
360
 
357
- // ---- vault_map ----
361
+ // ---- memory_map ----
358
362
 
359
363
  // Summary-mode caps. Full topology of a 300+ note vault crosses 25KB JSON
360
364
  // before it reaches the agent — most of that is per-cluster node lists and