@agfpd/iapeer-memory-core 0.1.1
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 +32 -0
- package/src/config.ts +257 -0
- package/src/context-render.ts +185 -0
- package/src/db.ts +550 -0
- package/src/embedding.ts +174 -0
- package/src/fm-update.ts +352 -0
- package/src/frontmatter-fill.ts +529 -0
- package/src/graph.ts +427 -0
- package/src/http-client.ts +129 -0
- package/src/human-edit-detect.ts +213 -0
- package/src/index-render.ts +876 -0
- package/src/index.ts +65 -0
- package/src/indexer.ts +323 -0
- package/src/log.ts +27 -0
- package/src/mcp-tools.ts +468 -0
- package/src/memoryd.ts +680 -0
- package/src/migrate-auto-memory.ts +289 -0
- package/src/parser.ts +269 -0
- package/src/permanent-detect.ts +110 -0
- package/src/render-doctrine.ts +113 -0
- package/src/reranker.ts +162 -0
- package/src/search.ts +806 -0
- package/src/smart-hash.ts +85 -0
- package/src/sqlite-loader.ts +151 -0
- package/src/tags-mirror.ts +47 -0
- package/src/taxonomy.ts +385 -0
- package/src/utils.ts +69 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-edit detector — attribution of edits made OUTSIDE harness sessions
|
|
3
|
+
* (external editors: Obsidian is one case, any editor works — нюанс 6: the
|
|
4
|
+
* component is generic fs logic, nothing Obsidian-specific).
|
|
5
|
+
*
|
|
6
|
+
* TS port of the pure core of the reference
|
|
7
|
+
* `scripts/mergemind-obsidian-watcher.cjs` (behavioural parity against
|
|
8
|
+
* `tests/obsidian-watcher.test.ts`, 17 fixtures). In iapeer-memory this is
|
|
9
|
+
* a memoryd SUBSYSTEM (ADR-004) — the daemon shell (fs.watch, debounce,
|
|
10
|
+
* hash persistence) arrives with the memoryd stage; everything
|
|
11
|
+
* load-bearing (the anti-loop decision core) lives here, pure.
|
|
12
|
+
*
|
|
13
|
+
* PERMANENT mode (the five canonical folders):
|
|
14
|
+
* - `last_edited_by == human` AND `updated` fresh → echo of our own
|
|
15
|
+
* write — skip;
|
|
16
|
+
* - `last_edited_by` is an agent AND fresh → the post-write hook just
|
|
17
|
+
* 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.
|
|
22
|
+
*
|
|
23
|
+
* `freshEditWindowS` is CONFIG (the stage-7 review note): default 90s —
|
|
24
|
+
* the reference VALUE IS 90 (bumped from the historical 30: second-
|
|
25
|
+
* precision `updated` removed minute-truncation loss, while iCloud event
|
|
26
|
+
* delivery can take tens of seconds under a sync storm; the asymmetry of
|
|
27
|
+
* harm favours the larger window — a human edit inside the window
|
|
28
|
+
* self-corrects on the next event, an agent placement mis-attributed as
|
|
29
|
+
* human used to loop permanently).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import crypto from "node:crypto";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import type { TaxonomyPreset } from "./taxonomy.js";
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_FRESH_EDIT_WINDOW_S = 90;
|
|
37
|
+
|
|
38
|
+
export function sha256(content: string): string {
|
|
39
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pad(n: number): string {
|
|
43
|
+
return String(n).padStart(2, "0");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Second precision — symmetric with frontmatter-fill's writer stamp. */
|
|
47
|
+
export function formatStamp(d: Date): string {
|
|
48
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse `updated` into ms. Strictly backward-compatible:
|
|
53
|
+
* `YYYY-MM-DD HH:MM:SS` (current), `YYYY-MM-DD HH:MM` (legacy, SS=0),
|
|
54
|
+
* `YYYY-MM-DD` (legacy date-only). Legacy formats are LOAD-BEARING:
|
|
55
|
+
* without them every touched old note mis-attributes as a human edit.
|
|
56
|
+
*/
|
|
57
|
+
export function parseUpdated(s: string | null | undefined): number | null {
|
|
58
|
+
if (!s) return null;
|
|
59
|
+
const trimmed = s.trim();
|
|
60
|
+
let m = /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(trimmed);
|
|
61
|
+
if (m) {
|
|
62
|
+
return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], m[6] ? +m[6] : 0).getTime();
|
|
63
|
+
}
|
|
64
|
+
m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
|
|
65
|
+
if (m) {
|
|
66
|
+
return new Date(+m[1], +m[2] - 1, +m[3]).getTime();
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function upsertField(fm: string, key: string, value: string): string {
|
|
72
|
+
const re = new RegExp(`^${key}\\s*:.*$`, "m");
|
|
73
|
+
if (re.test(fm)) {
|
|
74
|
+
return fm.replace(re, () => `${key}: ${value}`);
|
|
75
|
+
}
|
|
76
|
+
const tail = fm.endsWith("\n") ? "" : "\n";
|
|
77
|
+
return `${fm}${tail}${key}: ${value}\n`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function setIfMissing(fm: string, key: string, value: string): string {
|
|
81
|
+
const re = new RegExp(`^${key}\\s*:`, "m");
|
|
82
|
+
if (re.test(fm)) return fm;
|
|
83
|
+
const tail = fm.endsWith("\n") ? "" : "\n";
|
|
84
|
+
return `${fm}${tail}${key}: ${value}\n`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type HumanEditZone = "permanent" | "human-inbox";
|
|
88
|
+
|
|
89
|
+
/** Zone of a file for the detector: permanent / human-inbox / null. */
|
|
90
|
+
export function getZone(
|
|
91
|
+
filepath: string,
|
|
92
|
+
vault: string,
|
|
93
|
+
taxonomy: TaxonomyPreset,
|
|
94
|
+
): HumanEditZone | null {
|
|
95
|
+
const rel = path.relative(vault, filepath);
|
|
96
|
+
const first = rel.split(path.sep)[0];
|
|
97
|
+
const f = taxonomy.folders;
|
|
98
|
+
if (first === f.inboxHuman) return "human-inbox";
|
|
99
|
+
if (
|
|
100
|
+
first === f.knowledge ||
|
|
101
|
+
first === f.decisions ||
|
|
102
|
+
first === f.projects ||
|
|
103
|
+
first === f.ideas ||
|
|
104
|
+
first === f.lists
|
|
105
|
+
) {
|
|
106
|
+
return "permanent";
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type DecideUpdateInput = {
|
|
112
|
+
content: string;
|
|
113
|
+
zone: HumanEditZone;
|
|
114
|
+
human: string;
|
|
115
|
+
nowMs: number;
|
|
116
|
+
birthtimeMs: number;
|
|
117
|
+
mtimeMs: number;
|
|
118
|
+
basename: string;
|
|
119
|
+
lastHash: string | null;
|
|
120
|
+
taxonomy: TaxonomyPreset;
|
|
121
|
+
/** Config (default DEFAULT_FRESH_EDIT_WINDOW_S). */
|
|
122
|
+
freshEditWindowS?: number;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export type DecideUpdateResult =
|
|
126
|
+
| { action: "skip"; recordHash: string | null; reason: string }
|
|
127
|
+
| { action: "write"; newContent: string; recordHash: string; reason: string };
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The pure core: decides what to do from file content and context. No I/O,
|
|
131
|
+
* no module state — all the load-bearing anti-loop logic is here.
|
|
132
|
+
* `recordHash` is what the caller must store in its last-seen map
|
|
133
|
+
* (null = leave untouched, parity with the reference).
|
|
134
|
+
*/
|
|
135
|
+
export function decideUpdate(input: DecideUpdateInput): DecideUpdateResult {
|
|
136
|
+
const currentHash = sha256(input.content);
|
|
137
|
+
const windowS = input.freshEditWindowS ?? DEFAULT_FRESH_EDIT_WINDOW_S;
|
|
138
|
+
|
|
139
|
+
// Content-hash guard: an iCloud mtime-only event without a content change.
|
|
140
|
+
if (input.lastHash === currentHash) {
|
|
141
|
+
return { action: "skip", recordHash: null, reason: "content-unchanged" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fmMatch = /^---[^\S\n]*\n([\s\S]*?)\n---[^\S\n]*(?:\n|$)/.exec(input.content);
|
|
145
|
+
let fmBlock: string;
|
|
146
|
+
let body: string;
|
|
147
|
+
if (fmMatch) {
|
|
148
|
+
fmBlock = fmMatch[1];
|
|
149
|
+
body = input.content.slice(fmMatch[0].length);
|
|
150
|
+
} else if (input.zone === "human-inbox") {
|
|
151
|
+
fmBlock = "";
|
|
152
|
+
body = input.content;
|
|
153
|
+
} else {
|
|
154
|
+
// PERMANENT without frontmatter — not our zone.
|
|
155
|
+
return { action: "skip", recordHash: null, reason: "permanent-no-frontmatter" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const lebMatch = /^last_edited_by\s*:\s*(.+?)\s*$/m.exec(fmBlock);
|
|
159
|
+
const updMatch = /^updated\s*:\s*(.+?)\s*$/m.exec(fmBlock);
|
|
160
|
+
const currentLeb = lebMatch ? lebMatch[1].trim() : null;
|
|
161
|
+
const currentUpd = updMatch ? updMatch[1].trim() : null;
|
|
162
|
+
|
|
163
|
+
const editAt = parseUpdated(currentUpd);
|
|
164
|
+
const isFresh = editAt !== null && (input.nowMs - editAt) / 1000 < windowS;
|
|
165
|
+
|
|
166
|
+
// Case 1: our own echo — our write came back through the fs watch.
|
|
167
|
+
if (currentLeb === input.human && isFresh) {
|
|
168
|
+
return { action: "skip", recordHash: currentHash, reason: "echo-human" };
|
|
169
|
+
}
|
|
170
|
+
// Case 2: a fresh agent edit — the post-write hook just ran.
|
|
171
|
+
if (currentLeb && currentLeb !== input.human && isFresh) {
|
|
172
|
+
return { action: "skip", recordHash: currentHash, reason: "echo-agent" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Otherwise — an external-editor edit (or a new human-inbox file).
|
|
176
|
+
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
|
+
}
|
|
199
|
+
|
|
200
|
+
if (newFm === fmBlock) {
|
|
201
|
+
return { action: "skip", recordHash: currentHash, reason: "noop" };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const fmTail = newFm.endsWith("\n") ? "" : "\n";
|
|
205
|
+
const bodyPrefix = body.startsWith("\n") ? "" : "\n";
|
|
206
|
+
const newContent = `---\n${newFm}${fmTail}---${bodyPrefix}${body}`;
|
|
207
|
+
return {
|
|
208
|
+
action: "write",
|
|
209
|
+
newContent,
|
|
210
|
+
recordHash: sha256(newContent),
|
|
211
|
+
reason: input.zone === "human-inbox" ? "fill" : "stamp",
|
|
212
|
+
};
|
|
213
|
+
}
|