@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/src/human-edit-detect.ts
CHANGED
|
@@ -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
|
-
*
|
|
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:
|
|
19
|
-
* `last_edited_by: <human>` +
|
|
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"
|
|
93
|
+
export type HumanEditZone = "permanent";
|
|
88
94
|
|
|
89
|
-
/** Zone of a file for the detector: permanent
|
|
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
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 =
|
|
206
|
-
const newContent = `---\n${newFm}${fmTail}---${bodyPrefix}${
|
|
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:
|
|
226
|
+
reason: "stamp",
|
|
212
227
|
};
|
|
213
228
|
}
|
package/src/index-render.ts
CHANGED
|
@@ -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 `
|
|
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 {
|
|
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
|
-
*
|
|
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 мог упасть на
|
|
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
|
-
// ----
|
|
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
|
|
51
|
-
* backlinks are covered by
|
|
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 = ["
|
|
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
|
|
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.
|
|
114
|
-
//
|
|
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
|
|
149
|
-
//
|
|
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
|
-
// ----
|
|
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 {
|
|
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
|
-
// ----
|
|
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
|