@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/package.json +1 -1
- package/src/archive.ts +88 -0
- package/src/config.ts +7 -10
- package/src/context-render.ts +15 -11
- package/src/db.ts +2 -2
- package/src/embedding.ts +1 -1
- package/src/frontmatter-fill.ts +210 -56
- package/src/graph.ts +1 -1
- package/src/human-edit-detect.ts +53 -38
- package/src/index-render.ts +1 -1
- package/src/index.ts +53 -1
- package/src/indexer.ts +1 -1
- package/src/mcp-tools.ts +17 -13
- package/src/memoryd.ts +273 -205
- package/src/mode.ts +76 -0
- package/src/permanent-detect.ts +4 -82
- package/src/search.ts +72 -2
- package/src/silent-edit-detect.ts +11 -20
- package/src/tags-gate.ts +174 -0
- package/src/taxonomy.ts +79 -15
- package/src/utils.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer-memory-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/archive.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic archiving — lean §2.2a.
|
|
3
|
+
*
|
|
4
|
+
* In lean, archiving leaves the Index overlay and becomes BASE (0 LLM): a
|
|
5
|
+
* note whose `status` is a FINAL token (`isStale` — устарело/завершён/…; «на
|
|
6
|
+
* паузе» is PENDING, not stale → a resumable note is never archived) is moved
|
|
7
|
+
* to the archive folder by memoryd. The decision is taxonomy, not judgement.
|
|
8
|
+
*
|
|
9
|
+
* Wikilinks resolve by TITLE, so the graph survives the move (edges are
|
|
10
|
+
* reindexed); the archive is NOT excluded from search (it stays findable with
|
|
11
|
+
* the stale boost). The move is flat (`07_Archive/<basename>`), collisions
|
|
12
|
+
* resolved with a numeric suffix.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { isStale, type TaxonomyPreset } from "./taxonomy.js";
|
|
17
|
+
|
|
18
|
+
/** First path segment of a vault-relative path. */
|
|
19
|
+
function firstSegment(relPath: string): string {
|
|
20
|
+
return relPath.split(/[\\/]/)[0] ?? "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Folders whose notes are subject to archiving — UNIFIED rule, no exceptions
|
|
25
|
+
* (decision Артур 15.06, §2.2a): the six monitored content folders (five
|
|
26
|
+
* canonical permanent + agent memory + `03_Projects`). NOT the archive
|
|
27
|
+
* itself or the system folder. A completed phase/project
|
|
28
|
+
* (status `completed`/`cancelled`) is stale like any other note and moves to
|
|
29
|
+
* the archive; active `03_Projects` then shows only live work. Wikilinks
|
|
30
|
+
* survive by title; the archive stays searchable.
|
|
31
|
+
*/
|
|
32
|
+
export function isArchivableZone(relPath: string, taxonomy: TaxonomyPreset): boolean {
|
|
33
|
+
const f = taxonomy.folders;
|
|
34
|
+
const head = firstSegment(relPath);
|
|
35
|
+
return (
|
|
36
|
+
head === f.knowledge ||
|
|
37
|
+
head === f.decisions ||
|
|
38
|
+
head === f.projects ||
|
|
39
|
+
head === f.ideas ||
|
|
40
|
+
head === f.lists ||
|
|
41
|
+
head === f.agentMemory
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read `status` from a note's frontmatter (null when absent/no frontmatter). */
|
|
46
|
+
export function statusOf(content: string): string | null {
|
|
47
|
+
const fm = /^---[^\S\n]*\n([\s\S]*?)\n---/.exec(content);
|
|
48
|
+
if (!fm) return null;
|
|
49
|
+
const m = /^status\s*:\s*(.+?)\s*$/m.exec(fm[1]);
|
|
50
|
+
return m ? m[1].trim() : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Should this note be archived? In an archivable content zone AND carrying a
|
|
55
|
+
* final (stale) status. Notes already in the archive are excluded by
|
|
56
|
+
* `isArchivableZone` (the archive folder is not in the set).
|
|
57
|
+
*/
|
|
58
|
+
export function shouldArchive(
|
|
59
|
+
relPath: string,
|
|
60
|
+
content: string,
|
|
61
|
+
taxonomy: TaxonomyPreset,
|
|
62
|
+
): boolean {
|
|
63
|
+
if (!isArchivableZone(relPath, taxonomy)) return false;
|
|
64
|
+
return isStale(taxonomy, statusOf(content));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Flat archive target (vault-relative): `<archive>/<basename>`, with a numeric
|
|
69
|
+
* suffix on collision (`<stem>-2.md`, `-3.md`, …). `exists` answers whether a
|
|
70
|
+
* vault-relative path is already taken.
|
|
71
|
+
*/
|
|
72
|
+
export function archiveTargetRel(
|
|
73
|
+
basename: string,
|
|
74
|
+
taxonomy: TaxonomyPreset,
|
|
75
|
+
exists: (rel: string) => boolean,
|
|
76
|
+
): string {
|
|
77
|
+
const arch = taxonomy.folders.archive;
|
|
78
|
+
const isMd = basename.endsWith(".md");
|
|
79
|
+
const stem = isMd ? basename.slice(0, -3) : basename;
|
|
80
|
+
const ext = isMd ? ".md" : "";
|
|
81
|
+
let candidate = `${arch}/${basename}`;
|
|
82
|
+
let n = 2;
|
|
83
|
+
while (exists(candidate)) {
|
|
84
|
+
candidate = `${arch}/${stem}-${n}${ext}`;
|
|
85
|
+
n += 1;
|
|
86
|
+
}
|
|
87
|
+
return candidate;
|
|
88
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -50,15 +50,13 @@ export type CoreConfig = {
|
|
|
50
50
|
dbPath: string;
|
|
51
51
|
fullScanOnStartup: boolean;
|
|
52
52
|
};
|
|
53
|
-
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
53
|
+
/** Каденция курации (директива Артура 10.06 ~15:31): канон-правки уходят
|
|
54
|
+
* ПАЧКОЙ, не событиями — правки должны устаканиться, 10 пишущих агентов
|
|
55
|
+
* не дёргают конвейер постоянно. */
|
|
56
56
|
batch: {
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
* как в старом контуре: человек пишет долго, ночная партия). */
|
|
61
|
-
humanInboxHour: number;
|
|
57
|
+
/** CURATOR_TICK period, ms (default 6h) — the curation pass over changed
|
|
58
|
+
* canon (Scriber/Index links + health). */
|
|
59
|
+
curatorMs: number;
|
|
62
60
|
};
|
|
63
61
|
/**
|
|
64
62
|
* MCP-http endpoint of memoryd (ADR-012). The default port 8766 is the
|
|
@@ -261,8 +259,7 @@ export function configFromEnv(): CoreConfig {
|
|
|
261
259
|
fullScanOnStartup: envBoolean("IAPEER_MEMORY_FULL_SCAN_ON_STARTUP", true),
|
|
262
260
|
},
|
|
263
261
|
batch: {
|
|
264
|
-
|
|
265
|
-
humanInboxHour: envNumber("IAPEER_MEMORY_HUMAN_INBOX_HOUR", 4),
|
|
262
|
+
curatorMs: envNumber("IAPEER_MEMORY_CURATOR_TICK_SECS", 6 * 3600) * 1000,
|
|
266
263
|
},
|
|
267
264
|
mcp: {
|
|
268
265
|
port: envNumber("IAPEER_MEMORY_MCP_PORT", 8766),
|
package/src/context-render.ts
CHANGED
|
@@ -74,16 +74,20 @@ export type FragmentEnv = {
|
|
|
74
74
|
};
|
|
75
75
|
/** Rendered author index file (capped variant), absolute path. */
|
|
76
76
|
authorIndexPath: string;
|
|
77
|
-
/**
|
|
78
|
-
|
|
77
|
+
/** Compact tags-dictionary projection, absolute path — injected to EVERY
|
|
78
|
+
* author in lean (§3), not just the Index. */
|
|
79
|
+
tagsProjectionPath?: string;
|
|
80
|
+
/** Layer title for the projection (the dictionary file name, e.g. `Теги.md`);
|
|
81
|
+
* defaults to the projection file basename. */
|
|
82
|
+
tagsTitle?: string;
|
|
79
83
|
};
|
|
80
84
|
|
|
81
85
|
/**
|
|
82
86
|
* Assemble the per-peer fragment layers in the reference build_layers
|
|
83
|
-
* order
|
|
84
|
-
*
|
|
85
|
-
* is the role doctrine; the guide arrives host-wide by layer
|
|
86
|
-
* Missing/empty sources are skipped gracefully.
|
|
87
|
+
* order: paths → tags-dictionary projection (lean §3: ALL authors now, not
|
|
88
|
+
* only the Index) → author index. The curator gets no writer guide — its
|
|
89
|
+
* contract is the role doctrine; the guide arrives host-wide by layer
|
|
90
|
+
* mechanics. Missing/empty sources are skipped gracefully.
|
|
87
91
|
*/
|
|
88
92
|
export function buildLayers(env: FragmentEnv): ContextLayer[] {
|
|
89
93
|
const layers: ContextLayer[] = [];
|
|
@@ -101,11 +105,11 @@ export function buildLayers(env: FragmentEnv): ContextLayer[] {
|
|
|
101
105
|
].join("\n");
|
|
102
106
|
layers.push(["iapeer-memory paths", pathsBlock]);
|
|
103
107
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
if (env.tagsProjectionPath) {
|
|
109
|
+
const tags = readFileOrEmpty(env.tagsProjectionPath);
|
|
110
|
+
if (tags.trim()) {
|
|
111
|
+
layers.push([env.tagsTitle || path.basename(env.tagsProjectionPath), tags]);
|
|
112
|
+
}
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
const idx = readFileOrEmpty(env.authorIndexPath);
|
package/src/db.ts
CHANGED
|
@@ -144,7 +144,7 @@ export function openDatabase(config: CoreConfig, options: OpenDatabaseOptions =
|
|
|
144
144
|
|
|
145
145
|
-- Wikilinks that could not be resolved to a real note. Kept first-class
|
|
146
146
|
-- instead of being silently dropped from edges: a missing/ambiguous link
|
|
147
|
-
-- is a vault health signal (surfaced via
|
|
147
|
+
-- is a vault health signal (surfaced via memory_map orphan_wikilinks +
|
|
148
148
|
-- the Index nightly health-check). reason ∈ 'missing' | 'ambiguous'.
|
|
149
149
|
CREATE TABLE IF NOT EXISTS unresolved_links (
|
|
150
150
|
source_path TEXT NOT NULL,
|
|
@@ -536,7 +536,7 @@ export function documentExists(db: CoreDb, docPath: string): boolean {
|
|
|
536
536
|
|
|
537
537
|
/**
|
|
538
538
|
* Unresolved wikilinks (missing target / ambiguous basename across folders).
|
|
539
|
-
* First-class health signal — surfaced through
|
|
539
|
+
* First-class health signal — surfaced through memory_map's opt-in
|
|
540
540
|
* `orphan_wikilinks` part and the Index nightly health-check.
|
|
541
541
|
*/
|
|
542
542
|
export function getUnresolvedLinks(
|
package/src/embedding.ts
CHANGED
|
@@ -45,7 +45,7 @@ export type EmbeddingConfig = {
|
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Status of a single embedding call, surfaced up to the
|
|
48
|
+
* Status of a single embedding call, surfaced up to the memory_search
|
|
49
49
|
* response (`pipeline.embedding`).
|
|
50
50
|
*
|
|
51
51
|
* - `ok` — valid response;
|
package/src/frontmatter-fill.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fill a vault note's frontmatter according to its zone
|
|
3
|
-
* (
|
|
3
|
+
* (permanent / memory). The страж is the SHARED fill logic — the same
|
|
4
|
+
* functions run the agent hook path (processFile) and the human path
|
|
5
|
+
* (decideUpdate, human-edit-detect.ts). Inbox zones were removed with the
|
|
6
|
+
* direct-to-canon model: authors write straight into the typed canon folders.
|
|
4
7
|
*
|
|
5
8
|
* TS port of the reference `scripts/mergemind-frontmatter-fill.py`
|
|
6
9
|
* (behavioural parity against `tests/python/test_frontmatter_fill.py`,
|
|
@@ -16,20 +19,16 @@
|
|
|
16
19
|
* - **identity = peer personality** (нюанс 10): `resolveAgentName` prefers
|
|
17
20
|
* `PEER_PERSONALITY`, falling back to `IAPEER_MEMORY_AGENT_NAME`.
|
|
18
21
|
*
|
|
19
|
-
* Zone behaviour
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* -
|
|
28
|
-
*
|
|
29
|
-
* - memory: PERMANENT service-field semantics + idempotent fill of the
|
|
30
|
-
* constants. `author` is parsed from the subfolder name, NOT
|
|
31
|
-
* from the caller identity — load-bearing for DreamWeaver
|
|
32
|
-
* writing into a foreign subfolder on an Index task.
|
|
22
|
+
* Zone behaviour:
|
|
23
|
+
* - permanent: FULL canon fill (`fillPermanentFull`) — title ← filename,
|
|
24
|
+
* type/status ← the folder's genre (§2.1), created, author for
|
|
25
|
+
* non-curators + needs_review, and an UPSERT of the stamp pair
|
|
26
|
+
* (last_edited_by, updated). The author now hand-writes only
|
|
27
|
+
* body + tags + inline links + title; the rest is deterministic.
|
|
28
|
+
* - memory: service-field semantics + idempotent fill of the constants.
|
|
29
|
+
* `author` is parsed from the subfolder name, NOT from the caller
|
|
30
|
+
* identity — load-bearing for DreamWeaver writing into a foreign
|
|
31
|
+
* subfolder on an Index task.
|
|
33
32
|
*
|
|
34
33
|
* The YAML-safe normalisation of `description` is load-bearing: typographic
|
|
35
34
|
* guillemets `«…»` are a display convention, not YAML quotes; any `: ` inside
|
|
@@ -42,12 +41,12 @@ import fs from "node:fs";
|
|
|
42
41
|
import path from "node:path";
|
|
43
42
|
import crypto from "node:crypto";
|
|
44
43
|
import type { TaxonomyPreset } from "./taxonomy.js";
|
|
45
|
-
import { DEFAULT_CURATOR_SET } from "./taxonomy.js";
|
|
44
|
+
import { DEFAULT_CURATOR_SET, genreForFolder, linksSectionPattern } from "./taxonomy.js";
|
|
46
45
|
import { guardedWriteFileSync, guardedUnlinkSync } from "./fs-guard.js";
|
|
47
46
|
|
|
48
47
|
const FRONTMATTER_RE = /^---[^\S\n]*\n([\s\S]*?\n)---[^\S\n]*(?:\n|$)/;
|
|
49
48
|
|
|
50
|
-
export const VALID_ZONES = ["
|
|
49
|
+
export const VALID_ZONES = ["permanent", "memory"] as const;
|
|
51
50
|
export type Zone = (typeof VALID_ZONES)[number];
|
|
52
51
|
|
|
53
52
|
/**
|
|
@@ -96,6 +95,70 @@ export function setIfMissing(block: string, key: string, value: string): string
|
|
|
96
95
|
return `${block}${key}: ${value}\n`;
|
|
97
96
|
}
|
|
98
97
|
|
|
98
|
+
/** Read a scalar field value, or null when absent. */
|
|
99
|
+
export function readScalar(block: string, key: string): string | null {
|
|
100
|
+
const m = new RegExp(`^${escapeRe(key)}\\s*:\\s*(.+?)\\s*$`, "m").exec(block);
|
|
101
|
+
return m ? m[1].trim() : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Parse a YAML list field (block-list ` - item` or inline `[a, b]`). */
|
|
105
|
+
export function parseListField(block: string, key: string): string[] {
|
|
106
|
+
const lines = block.split("\n");
|
|
107
|
+
for (let i = 0; i < lines.length; i++) {
|
|
108
|
+
const m = new RegExp(`^${escapeRe(key)}\\s*:\\s*(.*)$`).exec(lines[i]);
|
|
109
|
+
if (!m) continue;
|
|
110
|
+
const inline = m[1].trim();
|
|
111
|
+
if (inline) {
|
|
112
|
+
return inline
|
|
113
|
+
.replace(/^\[/, "")
|
|
114
|
+
.replace(/\]$/, "")
|
|
115
|
+
.split(",")
|
|
116
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
const out: string[] = [];
|
|
120
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
121
|
+
const item = /^\s+-\s+(.*)$/.exec(lines[j]);
|
|
122
|
+
if (!item) break;
|
|
123
|
+
const v = item[1].trim().replace(/^["']|["']$/g, "");
|
|
124
|
+
if (v) out.push(v);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Remove a list field entirely (its `key:` line + any ` - item` lines). */
|
|
132
|
+
function removeListField(block: string, key: string): string {
|
|
133
|
+
const lines = block.split("\n");
|
|
134
|
+
const out: string[] = [];
|
|
135
|
+
const head = new RegExp(`^${escapeRe(key)}\\s*:`);
|
|
136
|
+
for (let i = 0; i < lines.length; i++) {
|
|
137
|
+
if (head.test(lines[i])) {
|
|
138
|
+
// skip the key line and the following block-list items
|
|
139
|
+
while (i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1])) i++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
out.push(lines[i]);
|
|
143
|
+
}
|
|
144
|
+
return out.join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Append `name` to `coauthors` (lean §3a auto-coauthor). No-op if already
|
|
149
|
+
* present. Rewrites the field as a normalised block-list at the end of the
|
|
150
|
+
* frontmatter — idempotent once the name is in the list.
|
|
151
|
+
*/
|
|
152
|
+
export function addCoauthor(block: string, name: string): string {
|
|
153
|
+
const existing = parseListField(block, "coauthors");
|
|
154
|
+
if (existing.includes(name)) return block;
|
|
155
|
+
let b = removeListField(block, "coauthors");
|
|
156
|
+
if (b && !b.endsWith("\n")) b += "\n";
|
|
157
|
+
const all = [...existing, name];
|
|
158
|
+
b += "coauthors:\n" + all.map((n) => ` - ${n}`).join("\n") + "\n";
|
|
159
|
+
return b;
|
|
160
|
+
}
|
|
161
|
+
|
|
99
162
|
/** Returns [fmBlock, rest]. No frontmatter → ["", content]. */
|
|
100
163
|
export function splitFrontmatter(content: string): [string, string] {
|
|
101
164
|
const m = FRONTMATTER_RE.exec(content);
|
|
@@ -138,8 +201,9 @@ export function parseMemoryAuthor(
|
|
|
138
201
|
* Zone of a file by the first path segment relative to the vault — the
|
|
139
202
|
* single source of truth for zone routing (the reference de-duplicated a
|
|
140
203
|
* bash `case` into exactly this function). Folder whitelist comes from the
|
|
141
|
-
* taxonomy preset (ADR-002):
|
|
142
|
-
*
|
|
204
|
+
* taxonomy preset (ADR-002): archive and system are NOT in the whitelist →
|
|
205
|
+
* null → caller no-ops. (Inbox zones removed with the direct-to-canon model:
|
|
206
|
+
* authors write straight into the typed canon folders, the страж fills.)
|
|
143
207
|
*/
|
|
144
208
|
export function resolveZone(
|
|
145
209
|
filePath: string,
|
|
@@ -151,7 +215,6 @@ export function resolveZone(
|
|
|
151
215
|
if (!parts || parts.length === 0) return null;
|
|
152
216
|
const head = parts[0];
|
|
153
217
|
const f = taxonomy.folders;
|
|
154
|
-
if (head === f.inbox) return "inbox";
|
|
155
218
|
if (
|
|
156
219
|
head === f.knowledge ||
|
|
157
220
|
head === f.decisions ||
|
|
@@ -165,46 +228,64 @@ export function resolveZone(
|
|
|
165
228
|
return null;
|
|
166
229
|
}
|
|
167
230
|
|
|
168
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Full permanent-zone (canon) fill — the lean §2 «страж» core, SHARED between
|
|
233
|
+
* the post-write hook (`processFile`) and the human-edit detector
|
|
234
|
+
* (`decideUpdate`), so an agent's write and a human's external write get
|
|
235
|
+
* identical deterministic frontmatter (mandate §2: «ОБЩАЯ логика из 2 путей»).
|
|
236
|
+
*
|
|
237
|
+
* Before lean the permanent branch was a near-empty stamp (canon frontmatter
|
|
238
|
+
* was supplied by the Index on placement). In lean the author writes only
|
|
239
|
+
* body + tags + organic inline links + a self-describing title; everything
|
|
240
|
+
* here is derived deterministically (0 LLM):
|
|
241
|
+
* - `title` ← file name; `type`/`status` ← the FOLDER's genre (§2.1);
|
|
242
|
+
* `created` ← today; `author` ← the writer (non-curator);
|
|
243
|
+
* - `last_edited_by`/`updated` ← the stamp pair (always upserted).
|
|
244
|
+
*/
|
|
245
|
+
export function fillPermanentFull(
|
|
169
246
|
fmBlock: string,
|
|
170
|
-
opts: {
|
|
247
|
+
opts: {
|
|
248
|
+
path: string;
|
|
249
|
+
agent: string;
|
|
250
|
+
vault: string;
|
|
251
|
+
today: string;
|
|
252
|
+
nowStamp: string;
|
|
253
|
+
ctx: FillContext;
|
|
254
|
+
},
|
|
171
255
|
): string {
|
|
172
256
|
const { taxonomy } = opts.ctx;
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// (null, null) — «штамп не двинулся» детектора немых записей выполняется
|
|
176
|
-
// на ЛЮБОЙ легитимной хук-правке, и inbox-ветка вырождается в «hash
|
|
177
|
-
// двинулся → unstamped» (7 ложных ре-стампов за день 11.06). Оба поля
|
|
178
|
-
// сервисные для smart-hash → семантический hash и INBOX_NEW-диффы не
|
|
179
|
-
// двигаются, эхо невозможно. Побочный выигрыш: source-фильтр кураторов
|
|
180
|
-
// INBOX_NEW впервые получает живой last_edited_by.
|
|
257
|
+
// Service stamp (always) — load-bearing for smart-hash echo-safety and the
|
|
258
|
+
// unstamped detector, symmetric with fillMemory.
|
|
181
259
|
fmBlock = upsert(fmBlock, "last_edited_by", opts.agent);
|
|
182
260
|
fmBlock = upsert(fmBlock, "updated", opts.nowStamp);
|
|
261
|
+
// Canon frontmatter the author no longer hand-writes (§2.1). setIfMissing —
|
|
262
|
+
// an explicit author value is never clobbered; a re-edit of an existing note
|
|
263
|
+
// is a stamp-only no-op on these.
|
|
183
264
|
fmBlock = setIfMissing(fmBlock, "title", basenameNoExt(opts.path));
|
|
184
|
-
|
|
265
|
+
const folder = opts.vault ? relParts(opts.path, opts.vault)?.[0] : undefined;
|
|
266
|
+
const genre = folder ? genreForFolder(taxonomy, folder) : null;
|
|
267
|
+
if (genre) {
|
|
268
|
+
fmBlock = setIfMissing(fmBlock, "type", genre.type);
|
|
269
|
+
fmBlock = setIfMissing(fmBlock, "status", genre.initialStatus);
|
|
270
|
+
}
|
|
185
271
|
fmBlock = setIfMissing(fmBlock, "created", opts.today);
|
|
186
|
-
// AUTHOR GUARD (
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
// видимая аномалия лучше тихо присвоенного авторства. Существующий
|
|
192
|
-
// author и без гарда неприкосновенен (setIfMissing).
|
|
272
|
+
// AUTHOR GUARD (Артур's invariant; fork-1 decision boris 15.06, §3a): a
|
|
273
|
+
// curator (Index/Scriber/DreamWeaver) edits canon STRUCTURE, never authors
|
|
274
|
+
// content — it never becomes `author` (nor, L2, `coauthors`). Same guard as
|
|
275
|
+
// the inbox branch. A non-curator writing canon IS the author. `needs_review`
|
|
276
|
+
// is the guard's flag, lifted only by Index/human.
|
|
193
277
|
if (!isCurator(opts.agent, opts.ctx)) {
|
|
194
278
|
fmBlock = setIfMissing(fmBlock, "author", opts.agent);
|
|
195
|
-
fmBlock = setIfMissing(fmBlock, "needs_review", "true");
|
|
196
|
-
}
|
|
197
|
-
return fmBlock;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export function fillPermanent(
|
|
201
|
-
fmBlock: string,
|
|
202
|
-
opts: { agent: string; nowStamp: string; ctx: FillContext },
|
|
203
|
-
): string {
|
|
204
|
-
fmBlock = upsert(fmBlock, "last_edited_by", opts.agent);
|
|
205
|
-
fmBlock = upsert(fmBlock, "updated", opts.nowStamp);
|
|
206
|
-
if (!isCurator(opts.agent, opts.ctx)) {
|
|
207
279
|
fmBlock = upsert(fmBlock, "needs_review", "true");
|
|
280
|
+
// §3a auto-coauthor: a non-curator who edits a canon note authored by
|
|
281
|
+
// SOMEONE ELSE is recorded as a coauthor — content collaboration that
|
|
282
|
+
// kills duplicates. Curators are excluded (fork-1 boris 15.06: they edit
|
|
283
|
+
// STRUCTURE, not content — same guard as the author guard). `author` is
|
|
284
|
+
// immutable; only `coauthors` grows. On a NEW note author===agent → no-op.
|
|
285
|
+
const author = readScalar(fmBlock, "author");
|
|
286
|
+
if (author && author !== opts.agent) {
|
|
287
|
+
fmBlock = addCoauthor(fmBlock, opts.agent);
|
|
288
|
+
}
|
|
208
289
|
}
|
|
209
290
|
return fmBlock;
|
|
210
291
|
}
|
|
@@ -417,6 +498,71 @@ export function normalizeFields(
|
|
|
417
498
|
return lines.join("\n");
|
|
418
499
|
}
|
|
419
500
|
|
|
501
|
+
/**
|
|
502
|
+
* YAML-safe normalisation of EVERY scalar frontmatter field (lean §2.2). Before
|
|
503
|
+
* lean only `description` was normalised; the «`: ` inside a plain scalar»
|
|
504
|
+
* failure (incident 49/538 unparseable) applies to ANY field — title, status,
|
|
505
|
+
* author, etc. Block-list fields (`tags`, `coauthors`) are EXCLUDED: they use
|
|
506
|
+
* the ` - item` form, and the inline `[..]`/`: ` heuristic would corrupt them.
|
|
507
|
+
* Idempotent (a clean-quoted value is left untouched).
|
|
508
|
+
*/
|
|
509
|
+
export function normalizeAllScalars(
|
|
510
|
+
fmBlock: string,
|
|
511
|
+
excludeKeys: readonly string[] = EMPTY_ARRAY_KEYS,
|
|
512
|
+
): string {
|
|
513
|
+
const lines = fmBlock.split("\n");
|
|
514
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
515
|
+
const m = NORMALIZE_LINE_RE.exec(lines[idx]);
|
|
516
|
+
if (!m || excludeKeys.includes(m[1])) continue;
|
|
517
|
+
const newVal = normalizeScalarValue(m[2]);
|
|
518
|
+
if (newVal !== null) lines[idx] = `${m[1]}: ${newVal}`;
|
|
519
|
+
}
|
|
520
|
+
return lines.join("\n");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Markdown thematic break: `---`, `***`, `___`, optionally spaced. */
|
|
524
|
+
const HR_LINE_RE = /^[ \t]*([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* A line that is a fuzzy match of the links-section heading: any `#` level,
|
|
528
|
+
* any spacing, any case, but the section text EXACTLY (so `## Связанные…`
|
|
529
|
+
* never matches `## Связи`).
|
|
530
|
+
*/
|
|
531
|
+
function isFuzzyLinksHeading(line: string, taxonomy: TaxonomyPreset): boolean {
|
|
532
|
+
const sectionText = taxonomy.linksSection.replace(/^#+\s*/, "");
|
|
533
|
+
const esc = sectionText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
534
|
+
return new RegExp(`^#{1,6}\\s*${esc}\\s*$`, "i").test(line.trim());
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Make a leading links-section block recognisable to the parser's
|
|
539
|
+
* `stripLinksSection` (heading at body start + a `---` divider), so the block
|
|
540
|
+
* is cut from search/embedding content instead of polluting BM25 with
|
|
541
|
+
* popular-target false hits (lean §2.2). CONSERVATIVE and mechanical (§10.2):
|
|
542
|
+
* only the heading line FORM and the block's HR divider are rewritten — never
|
|
543
|
+
* content, nothing inserted or moved. Body without a leading links heading →
|
|
544
|
+
* no-op. Idempotent.
|
|
545
|
+
*/
|
|
546
|
+
export function normalizeLinksBlock(body: string, taxonomy: TaxonomyPreset): string {
|
|
547
|
+
const lines = body.split("\n");
|
|
548
|
+
let i = 0;
|
|
549
|
+
while (i < lines.length && lines[i].trim() === "") i++;
|
|
550
|
+
if (i >= lines.length || !isFuzzyLinksHeading(lines[i], taxonomy)) return body;
|
|
551
|
+
if (lines[i] !== taxonomy.linksSection) lines[i] = taxonomy.linksSection;
|
|
552
|
+
// Normalise the block's first HR divider to `---`. Scan only across the link
|
|
553
|
+
// list (`- …` / `* …` items and blanks); a content line means no divider —
|
|
554
|
+
// leave it (the heading fix alone is the safe part).
|
|
555
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
556
|
+
if (HR_LINE_RE.test(lines[j])) {
|
|
557
|
+
if (lines[j].trim() !== "---") lines[j] = "---";
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
const t = lines[j].trim();
|
|
561
|
+
if (t !== "" && !t.startsWith("-") && !t.startsWith("*")) break;
|
|
562
|
+
}
|
|
563
|
+
return lines.join("\n");
|
|
564
|
+
}
|
|
565
|
+
|
|
420
566
|
/**
|
|
421
567
|
* Assemble the new file. A body not starting with a newline gets one, so the
|
|
422
568
|
* markdown parser sees the frontmatter separately from the first paragraph.
|
|
@@ -509,10 +655,17 @@ export function processFile(filePath: string, opts: ProcessOptions): boolean {
|
|
|
509
655
|
const [fmBlock, rest] = splitFrontmatter(content);
|
|
510
656
|
|
|
511
657
|
let newFm: string | null;
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
658
|
+
let newBody = rest;
|
|
659
|
+
if (zone === "permanent") {
|
|
660
|
+
newFm = fillPermanentFull(fmBlock, {
|
|
661
|
+
path: filePath,
|
|
662
|
+
agent: opts.agent,
|
|
663
|
+
vault,
|
|
664
|
+
today,
|
|
665
|
+
nowStamp,
|
|
666
|
+
ctx,
|
|
667
|
+
});
|
|
668
|
+
newBody = normalizeLinksBlock(rest, opts.taxonomy);
|
|
516
669
|
} else {
|
|
517
670
|
newFm = fillMemory(fmBlock, {
|
|
518
671
|
path: filePath,
|
|
@@ -523,11 +676,12 @@ export function processFile(filePath: string, opts: ProcessOptions): boolean {
|
|
|
523
676
|
ctx,
|
|
524
677
|
});
|
|
525
678
|
if (newFm === null) return false;
|
|
679
|
+
newBody = normalizeLinksBlock(rest, opts.taxonomy);
|
|
526
680
|
}
|
|
527
681
|
|
|
528
682
|
newFm = stripEmptyArrays(newFm);
|
|
529
|
-
newFm =
|
|
530
|
-
const newContent = assemble(newFm,
|
|
683
|
+
newFm = normalizeAllScalars(newFm);
|
|
684
|
+
const newContent = assemble(newFm, newBody);
|
|
531
685
|
if (newContent === content) return false;
|
|
532
686
|
atomicWrite(filePath, newContent);
|
|
533
687
|
return true;
|
package/src/graph.ts
CHANGED
|
@@ -54,7 +54,7 @@ function loadGraph(db: CoreDb, agentMemoryMarker: string): { adj: AdjMap; direct
|
|
|
54
54
|
target_path: string;
|
|
55
55
|
}>;
|
|
56
56
|
|
|
57
|
-
//
|
|
57
|
+
// memory_map is the CANONICAL / shared topology — used by the Index nightly
|
|
58
58
|
// health-check (orphans, hubs, bridges). Agent operative notes are personal
|
|
59
59
|
// memory: excluding them here keeps that health picture honest (a
|
|
60
60
|
// canonically-isolated note must read as orphan even if some agent journaled
|