rag-memory-epf-mcp 3.5.2 → 4.0.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.
@@ -0,0 +1,33 @@
1
+ export declare function resolveModelCacheDir(env: Record<string, string | undefined>, platform: string, homedir: string): string;
2
+ export declare function preflightCacheDir(dir: string): {
3
+ ok: boolean;
4
+ error?: string;
5
+ };
6
+ export declare function artifactKey(modelId: string, revision: string, dtype: string): string;
7
+ export declare class ModelDownloadLock {
8
+ readonly lockPath: string;
9
+ readonly markerPath: string;
10
+ private held;
11
+ constructor(cacheDir: string, key: string);
12
+ private tryAcquire;
13
+ private isStale;
14
+ private holderPid;
15
+ invalidateMarker(): void;
16
+ acquireOrWait(opts: {
17
+ pollMs?: number;
18
+ timeoutMs: number;
19
+ signal?: AbortSignal;
20
+ }): Promise<'owner' | 'ready'>;
21
+ markComplete(): void;
22
+ release(): void;
23
+ }
24
+ export declare function isCacheIntegrityError(e: unknown, cacheDir?: string): boolean;
25
+ export declare function handleLoaderFailure(opts: {
26
+ role: 'owner' | 'ready';
27
+ error: unknown;
28
+ lock: ModelDownloadLock;
29
+ cacheDir: string;
30
+ modelId: string;
31
+ terminal: boolean;
32
+ }): 'none' | 'marker-invalidated' | 'quarantined';
33
+ export declare function quarantinePartialCache(cacheDir: string, modelId: string): void;
@@ -0,0 +1,235 @@
1
+ // v3.6 lite install (spec 2026-07-18 v5 §7): version-independent model cache.
2
+ // The transformers.js default cache lives INSIDE the package directory, which is
3
+ // npx-slot scoped — every engine version bump re-downloads ~1.2GB. This module
4
+ // resolves a user-level cache dir and provides a cross-process download lock so
5
+ // concurrent MCP servers on one machine never write the same model concurrently
6
+ // (transformers' FileCache.put is not atomic).
7
+ //
8
+ // Lock/marker keys use ONLY cache-artifact fields (model id, revision, dtype) —
9
+ // never retrieval config (spec §6c layer 1).
10
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync, renameSync, rmSync, readdirSync } from 'node:fs';
11
+ import { join, sep } from 'node:path';
12
+ import { createHash } from 'node:crypto';
13
+ export function resolveModelCacheDir(env, platform, homedir) {
14
+ if (env.RAG_MEMORY_MODEL_CACHE_DIR)
15
+ return env.RAG_MEMORY_MODEL_CACHE_DIR;
16
+ if (env.XDG_CACHE_HOME)
17
+ return join(env.XDG_CACHE_HOME, 'rag-memory-epf-mcp');
18
+ if (platform === 'darwin')
19
+ return join(homedir, 'Library', 'Caches', 'rag-memory-epf-mcp');
20
+ if (platform === 'win32' && env.LOCALAPPDATA)
21
+ return join(env.LOCALAPPDATA, 'rag-memory-epf-mcp');
22
+ return join(homedir, '.cache', 'rag-memory-epf-mcp');
23
+ }
24
+ // mkdir -p + write probe. On failure the caller must transition the gate to
25
+ // `failed` — silently falling back to the package-internal cache is forbidden.
26
+ export function preflightCacheDir(dir) {
27
+ try {
28
+ mkdirSync(dir, { recursive: true });
29
+ const probe = join(dir, `.write-probe-${process.pid}`);
30
+ writeFileSync(probe, 'ok');
31
+ unlinkSync(probe);
32
+ return { ok: true };
33
+ }
34
+ catch (e) {
35
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
36
+ }
37
+ }
38
+ export function artifactKey(modelId, revision, dtype) {
39
+ return createHash('sha1').update(`${modelId}@${revision}#${dtype}`).digest('hex').slice(0, 12);
40
+ }
41
+ const STALE_LOCK_MS = 30 * 60_000;
42
+ export class ModelDownloadLock {
43
+ lockPath;
44
+ markerPath;
45
+ held = false;
46
+ constructor(cacheDir, key) {
47
+ this.lockPath = join(cacheDir, `.download-${key}.lock`);
48
+ this.markerPath = join(cacheDir, `.complete-${key}`);
49
+ }
50
+ tryAcquire() {
51
+ try {
52
+ writeFileSync(this.lockPath, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), { flag: 'wx' });
53
+ this.held = true;
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ // Stale = holder pid is dead or the lock is unreadable. A LIVE pid is never
61
+ // reclaimed by age alone (beta B5): a slow 1.2GB download has no heartbeat,
62
+ // so an mtime rule would mint a second concurrent owner — the exact failure
63
+ // this lock exists to prevent. A hung-but-alive holder instead surfaces as a
64
+ // waiter timeout -> gate 'failed' + backoff, with the lock path and holder
65
+ // pid in the error so an operator can verify and remove it manually (the
66
+ // recovery procedure lives in docs/UPDATING.md). No pid-reuse heuristic:
67
+ // there is no portable process-start identity to compare against (beta 2R).
68
+ isStale() {
69
+ try {
70
+ const info = JSON.parse(readFileSync(this.lockPath, 'utf-8'));
71
+ try {
72
+ process.kill(info.pid, 0);
73
+ }
74
+ catch {
75
+ return true;
76
+ } // pid dead
77
+ return false;
78
+ }
79
+ catch {
80
+ return true; // unreadable lock = stale
81
+ }
82
+ }
83
+ holderPid() {
84
+ try {
85
+ return JSON.parse(readFileSync(this.lockPath, 'utf-8')).pid;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ // Corrupted-cache recovery (beta B5): a marker only proves a PAST verified
92
+ // load. When a marker-holder ('ready' role) later fails to load, the caller
93
+ // must invalidate the marker so the next attempt becomes a locked owner.
94
+ invalidateMarker() {
95
+ try {
96
+ unlinkSync(this.markerPath);
97
+ }
98
+ catch { /* already gone */ }
99
+ }
100
+ // (beta 4R M1) Quarantine decisions are made by ERROR CLASS, not by failure
101
+ // count — see isCacheIntegrityError below.
102
+ // Returns 'owner' (caller must download, then markComplete + release) or
103
+ // 'ready' (a completed, verified cache already exists). Async polling only —
104
+ // never blocks the event loop (spec §7 / 3R M14).
105
+ async acquireOrWait(opts) {
106
+ const poll = opts.pollMs ?? 500;
107
+ const deadline = Date.now() + opts.timeoutMs;
108
+ for (;;) {
109
+ // Abort FIRST (beta 2R B5): a shutdown-aborted waiter must never become
110
+ // an owner or report 'ready' and start a pipeline load.
111
+ if (opts.signal?.aborted)
112
+ throw new Error('model download lock wait aborted');
113
+ if (existsSync(this.markerPath))
114
+ return 'ready';
115
+ if (this.tryAcquire())
116
+ return 'owner';
117
+ if (this.isStale()) {
118
+ try {
119
+ unlinkSync(this.lockPath);
120
+ }
121
+ catch { /* raced with another reclaimer */ }
122
+ continue;
123
+ }
124
+ if (Date.now() > deadline) {
125
+ throw new Error(`model download lock wait timed out — holder pid=${this.holderPid() ?? 'unknown'}, lock=${this.lockPath}. If that process is hung, verify and remove the lock file manually (see docs/UPDATING.md).`);
126
+ }
127
+ // Abortable sleep with symmetric listener cleanup (beta 3R M4): a
128
+ // 10-minute wait at 500ms polls must not accumulate ~1200 abort
129
+ // listeners on the shared shutdown signal.
130
+ await new Promise(resolve => {
131
+ const onAbort = () => { clearTimeout(t); resolve(); };
132
+ const t = setTimeout(() => {
133
+ opts.signal?.removeEventListener('abort', onAbort);
134
+ resolve();
135
+ }, poll);
136
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
137
+ });
138
+ }
139
+ }
140
+ // Only call after a verified cache load — a memory-only load success without
141
+ // durable cache files must NOT produce a marker (3R M14).
142
+ markComplete() {
143
+ const tmp = `${this.markerPath}.tmp.${process.pid}`;
144
+ writeFileSync(tmp, new Date().toISOString());
145
+ renameSync(tmp, this.markerPath);
146
+ }
147
+ release() {
148
+ if (this.held) {
149
+ try {
150
+ unlinkSync(this.lockPath);
151
+ }
152
+ catch { /* already gone */ }
153
+ this.held = false;
154
+ }
155
+ }
156
+ }
157
+ // (beta 4R M1 -> 5R M2) Error classification for cache handling: ONLY strong
158
+ // serialization/truncation/corruption signatures justify quarantining a 1.2GB
159
+ // cache. Generic words (parse, invalid model, byte length) and bare ENOENT
160
+ // match far too much outside the cache — ENOENT counts only when the message
161
+ // points INSIDE the model cache directory. OOM / allocation / network errors
162
+ // preserve the cache regardless of repetition. Residual risk (documented): a
163
+ // corruption with no matching signature stays put; docs/UPDATING.md tells the
164
+ // operator to delete the model cache directory manually.
165
+ const CACHE_INTEGRITY_SIGNATURES = [
166
+ /protobuf/i, /corrupt/i, /unexpected end/i, /deseriali[sz]e/i,
167
+ /magic number/i, /truncated/i, /checksum/i,
168
+ ];
169
+ const CACHE_PRESERVE_SIGNATURES = [
170
+ /out of memory/i, /bad_alloc/i, /allocation/i, /OOM/i, /ETIMEDOUT/, /ECONNRESET/, /fetch/i, /network/i,
171
+ ];
172
+ export function isCacheIntegrityError(e, cacheDir) {
173
+ const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
174
+ if (CACHE_PRESERVE_SIGNATURES.some(re => re.test(msg)))
175
+ return false;
176
+ if (CACHE_INTEGRITY_SIGNATURES.some(re => re.test(msg)))
177
+ return true;
178
+ // Missing-file errors are integrity ONLY when they point INSIDE the cache
179
+ // dir (path-boundary-safe: '/cache' must not match '/cache-old' — 6R note).
180
+ if (cacheDir && (/ENOENT/.test(msg) || /no such file/i.test(msg))
181
+ && (msg.includes(cacheDir + sep) || msg.includes(cacheDir + '/')))
182
+ return true;
183
+ return false;
184
+ }
185
+ // (beta 5R M1) Loader-failure cache policy, extracted for unit testing.
186
+ // Quarantine requires EXCLUSIVITY: only the lock-holding OWNER may rename or
187
+ // delete shared cache files — a ready-role process (no lock) racing other
188
+ // readers must never touch the directory; it only drops the marker so the
189
+ // next retry becomes a locked owner and re-proves the same integrity error
190
+ // before any destructive action.
191
+ export function handleLoaderFailure(opts) {
192
+ if (opts.terminal)
193
+ return 'none'; // config error: cache is fine
194
+ if (!isCacheIntegrityError(opts.error, opts.cacheDir)) {
195
+ // OOM / network / unknown: preserve everything (marker included — a
196
+ // transient error does not disprove a verified cache).
197
+ return 'none';
198
+ }
199
+ if (opts.role === 'ready') {
200
+ opts.lock.invalidateMarker();
201
+ return 'marker-invalidated';
202
+ }
203
+ opts.lock.invalidateMarker();
204
+ quarantinePartialCache(opts.cacheDir, opts.modelId);
205
+ return 'quarantined';
206
+ }
207
+ // Owner-side failure handling: partially downloaded model dirs are quarantined
208
+ // (renamed aside) rather than left in place, so the next attempt starts clean.
209
+ // transformers.js FileCache keys are `<org>/<model>/...` — the on-disk layout
210
+ // nests the model id's path segments under cacheDir (beta B5: a flattened
211
+ // `org_model` path would miss the real files entirely).
212
+ export function quarantinePartialCache(cacheDir, modelId) {
213
+ const dir = join(cacheDir, ...modelId.split('/'));
214
+ if (existsSync(dir)) {
215
+ try {
216
+ renameSync(dir, `${dir}.quarantine.${Date.now()}`);
217
+ }
218
+ catch {
219
+ rmSync(dir, { recursive: true, force: true });
220
+ }
221
+ }
222
+ // Retention (beta 2R B3): keep only the newest quarantine — repeated failures
223
+ // must not accumulate 1.2GB directories.
224
+ try {
225
+ const parent = join(cacheDir, ...modelId.split('/').slice(0, -1));
226
+ const leaf = modelId.split('/').pop();
227
+ const quarantines = readdirSync(parent)
228
+ .filter(f => f.startsWith(`${leaf}.quarantine.`))
229
+ .sort();
230
+ for (const old of quarantines.slice(0, -1)) {
231
+ rmSync(join(parent, old), { recursive: true, force: true });
232
+ }
233
+ }
234
+ catch { /* parent may not exist */ }
235
+ }
@@ -0,0 +1,8 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare function getObservationHistory(db: Database.Database, sel: {
3
+ entity_name?: string;
4
+ observation_id?: string;
5
+ root_id?: string;
6
+ }): {
7
+ roots: any[];
8
+ };
@@ -0,0 +1,64 @@
1
+ // spec §6.2: history 의 유일한 창구.
2
+ // 응답은 항상 roots 배열이다 (entity_name 이면 N개, root_id/observation_id 면 1개).
3
+ // 단일 객체로 두면 entity_name 선택자와 cardinality 가 모순된다.
4
+ export function getObservationHistory(db, sel) {
5
+ // 정확히 하나만 받는다. 우선순위를 조용히 적용하면 두 개를 넘긴 호출자가
6
+ // *다른* 선택자의 답을 받고 그 사실을 모른다 — 실측으로 entity_name 이 유효한데
7
+ // 존재하지 않는 root_id 가 이겨서 `{roots: []}` 가 나갔다(advisor beta 발견 4-2).
8
+ const given = ['entity_name', 'observation_id', 'root_id']
9
+ .filter(k => sel[k] !== undefined && sel[k] !== null && sel[k] !== '');
10
+ if (given.length === 0) {
11
+ throw new Error('getObservationHistory requires one of: entity_name, observation_id, root_id');
12
+ }
13
+ if (given.length > 1) {
14
+ throw new Error(`getObservationHistory takes exactly one selector, got ${given.length} (${given.join(', ')}). ` +
15
+ `Applying a precedence order would silently answer a different question than the one asked.`);
16
+ }
17
+ let rootIds;
18
+ if (sel.root_id) {
19
+ rootIds = [sel.root_id];
20
+ }
21
+ else if (sel.observation_id) {
22
+ const r = db.prepare(`SELECT root_id FROM entity_observations WHERE observation_id = ?`)
23
+ .get(sel.observation_id);
24
+ rootIds = r ? [r.root_id] : [];
25
+ }
26
+ else if (sel.entity_name) {
27
+ const entityId = `entity_${sel.entity_name.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
28
+ rootIds = db.prepare(`SELECT root_id FROM observation_roots
29
+ WHERE entity_id = ? ORDER BY projection_order`)
30
+ .all(entityId).map(r => r.root_id);
31
+ }
32
+ else {
33
+ throw new Error('getObservationHistory requires one of: entity_name, observation_id, root_id');
34
+ }
35
+ const roots = rootIds.map(rid => {
36
+ const root = db.prepare(`SELECT * FROM observation_roots WHERE root_id = ?`).get(rid);
37
+ if (!root)
38
+ return null;
39
+ const revs = db.prepare(`SELECT * FROM entity_observations WHERE root_id = ? ORDER BY revision_no`).all(rid);
40
+ // 정렬 = recorded_at, 동시각이면 event_id 사전순 (결정론)
41
+ const events = db.prepare(`SELECT * FROM observation_events WHERE root_id = ? ORDER BY recorded_at, event_id`).all(rid);
42
+ return {
43
+ root_id: root.root_id,
44
+ entity_id: root.entity_id,
45
+ projection_order: root.projection_order,
46
+ revisions: revs.map(v => ({
47
+ observation_id: v.observation_id,
48
+ revision_no: v.revision_no,
49
+ content: v.content,
50
+ status: v.status,
51
+ supersedes_id: v.supersedes_id,
52
+ recorded_at: v.recorded_at,
53
+ superseded_at: v.superseded_at,
54
+ sources: db.prepare(`SELECT source_kind, source_ref, source_hash, recorded_at
55
+ FROM observation_sources WHERE observation_id = ?
56
+ ORDER BY recorded_at, source_kind, source_ref`).all(v.observation_id),
57
+ // 이 revision 을 from/to 로 지목한 event 만
58
+ events: events.filter(e => e.from_id === v.observation_id || e.to_id === v.observation_id),
59
+ })),
60
+ };
61
+ }).filter(Boolean);
62
+ roots.sort((a, b) => a.projection_order - b.projection_order);
63
+ return { roots };
64
+ }
@@ -0,0 +1,45 @@
1
+ import type Database from 'better-sqlite3';
2
+ export type ObsStatus = 'active' | 'superseded' | 'retracted' | 'provisional';
3
+ export type ObsEvent = 'add' | 'correct' | 'retract' | 'restore' | 'approve' | 'decline' | 'import';
4
+ export type SourceInput = {
5
+ source_kind: 'document' | 'conversation' | 'decision' | 'import';
6
+ source_ref: string;
7
+ source_hash?: string | null;
8
+ };
9
+ export declare function nextProjectionOrder(db: Database.Database, entityId: string): number;
10
+ export declare function recordEvent(db: Database.Database, a: {
11
+ rootId: string;
12
+ event: ObsEvent;
13
+ fromId?: string | null;
14
+ toId?: string | null;
15
+ changeKind?: 'correction' | 'world_change' | 'retraction' | null;
16
+ reason?: string | null;
17
+ actor?: string | null;
18
+ batchId?: string | null;
19
+ ts: string;
20
+ }): void;
21
+ export declare function linkSources(db: Database.Database, observationId: string, sources: SourceInput[], ts: string): number;
22
+ export declare function addRevision(db: Database.Database, a: {
23
+ entityId: string;
24
+ content: string;
25
+ status: Extract<ObsStatus, 'active' | 'provisional'>;
26
+ sources?: SourceInput[];
27
+ actor?: string | null;
28
+ ts: string;
29
+ event?: Extract<ObsEvent, 'add' | 'import'>;
30
+ }): string;
31
+ export declare function correctRevision(db: Database.Database, a: {
32
+ observationId: string;
33
+ content: string;
34
+ changeKind: 'correction' | 'world_change';
35
+ reason?: string | null;
36
+ actor?: string | null;
37
+ ts: string;
38
+ }): string;
39
+ export declare function transitionStatus(db: Database.Database, a: {
40
+ observationId: string;
41
+ event: 'retract' | 'restore' | 'approve' | 'decline';
42
+ reason?: string | null;
43
+ actor?: string | null;
44
+ ts: string;
45
+ }): void;
@@ -0,0 +1,101 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ // 순번 정본은 observation_roots 다. entity_observations 로 세면 revision 이
3
+ // purge 된 root 의 순번을 재사용해 이후 restore/approve 가 UNIQUE 로 실패한다.
4
+ export function nextProjectionOrder(db, entityId) {
5
+ const r = db.prepare(`SELECT COALESCE(MAX(projection_order), -1) + 1 AS n FROM observation_roots WHERE entity_id = ?`).get(entityId);
6
+ return r.n;
7
+ }
8
+ export function recordEvent(db, a) {
9
+ db.prepare(`INSERT INTO observation_events
10
+ (event_id, root_id, from_id, to_id, event, change_kind, reason, actor, batch_id, recorded_at)
11
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
12
+ .run(randomUUID(), a.rootId, a.fromId ?? null, a.toId ?? null, a.event, a.changeKind ?? null, a.reason ?? null, a.actor ?? null, a.batchId ?? null, a.ts);
13
+ }
14
+ // 한 관찰에 evidence 를 더한다. 같은 사실이 다른 출처에서 다시 오면
15
+ // 새 revision 이 아니라 source link 가 늘어난다(spec §8.3 T13).
16
+ export function linkSources(db, observationId, sources, ts) {
17
+ let n = 0;
18
+ for (const s of sources) {
19
+ n += db.prepare(`INSERT OR IGNORE INTO observation_sources
20
+ (observation_id, source_kind, source_ref, source_hash, recorded_at)
21
+ VALUES (?, ?, ?, ?, ?)`)
22
+ .run(observationId, s.source_kind, s.source_ref, s.source_hash ?? null, ts).changes;
23
+ }
24
+ return n;
25
+ }
26
+ // 새 논리 관찰 = root 1 + rev1 1 + sources N + event 1.
27
+ // event 는 기본 'add' 지만 legacy import 경로는 'import' 를 남겨야 한다(spec §6.4):
28
+ // 그 관찰은 사람이 방금 추가한 것이 아니라 옛 형식 dump 에서 승격된 것이고,
29
+ // 'add' 로 적으면 history 가 유입 경로를 잘못 말한다(advisor beta 발견 1).
30
+ export function addRevision(db, a) {
31
+ const rootId = randomUUID();
32
+ const obsId = randomUUID();
33
+ const order = nextProjectionOrder(db, a.entityId);
34
+ db.prepare(`INSERT INTO observation_roots (root_id, entity_id, projection_order, created_at)
35
+ VALUES (?, ?, ?, ?)`).run(rootId, a.entityId, order, a.ts);
36
+ db.prepare(`INSERT INTO entity_observations
37
+ (observation_id, root_id, entity_id, revision_no, projection_order,
38
+ content, status, supersedes_id, recorded_at, superseded_at)
39
+ VALUES (?, ?, ?, 1, ?, ?, ?, NULL, ?, NULL)`)
40
+ .run(obsId, rootId, a.entityId, order, a.content, a.status, a.ts);
41
+ // 출처를 모르면 source 행을 만들지 않는다.
42
+ // source_ref 가 NOT NULL 인 것은 "행을 반드시 만들라"는 뜻이 아니다.
43
+ // sources 가 생략됐는데 conversation/unspecified 를 넣으면 "대화에서 왔다"는
44
+ // 사실을 발명하는 것이고, provenance 의 목적과 정반대다
45
+ // (advisor 구현리뷰 r1 발견 5). unknown 은 0행으로 표현한다.
46
+ if (a.sources?.length)
47
+ linkSources(db, obsId, a.sources, a.ts);
48
+ recordEvent(db, { rootId, event: a.event ?? 'add', toId: obsId,
49
+ actor: a.actor ?? null, ts: a.ts });
50
+ return obsId;
51
+ }
52
+ // §4.4 복합 전이: ①구 active -> superseded ②신규 revision -> active ③correct event.
53
+ // 셋이 한 트랜잭션 안에서 일어나야 한다 — 호출자가 mutateEntityAndInvalidate 로 감싼다.
54
+ export function correctRevision(db, a) {
55
+ const cur = db.prepare(`SELECT observation_id, root_id, entity_id, revision_no, projection_order, status
56
+ FROM entity_observations WHERE observation_id = ?`).get(a.observationId);
57
+ if (!cur)
58
+ throw new Error(`observation ${a.observationId} not found`);
59
+ if (cur.status !== 'active')
60
+ throw new Error(`cannot correct an observation in status '${cur.status}' — only 'active' (spec §4.4)`);
61
+ const newId = randomUUID();
62
+ // ① 구 행을 먼저 superseded 로. active-per-root 부분 UNIQUE 때문에 순서가 계약이다.
63
+ db.prepare(`UPDATE entity_observations SET status='superseded', superseded_at=? WHERE observation_id=?`)
64
+ .run(a.ts, cur.observation_id);
65
+ // ② 신규 revision. projection_order 는 전임자 상속 = 배열 위치가 움직이지 않는다.
66
+ db.prepare(`INSERT INTO entity_observations
67
+ (observation_id, root_id, entity_id, revision_no, projection_order,
68
+ content, status, supersedes_id, recorded_at, superseded_at)
69
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, NULL)`)
70
+ .run(newId, cur.root_id, cur.entity_id, cur.revision_no + 1, cur.projection_order, a.content, cur.observation_id, a.ts);
71
+ // ③ event
72
+ recordEvent(db, { rootId: cur.root_id, event: 'correct', fromId: cur.observation_id,
73
+ toId: newId, changeKind: a.changeKind, reason: a.reason ?? null,
74
+ actor: a.actor ?? null, ts: a.ts });
75
+ return newId;
76
+ }
77
+ // §4.4 전이 표를 코드로. 표에 없는 조합은 거부한다 — 'superseded' 는 종착이다.
78
+ const ALLOWED = {
79
+ active: [{ to: 'retracted', event: 'retract' }],
80
+ retracted: [{ to: 'active', event: 'restore' }],
81
+ provisional: [{ to: 'active', event: 'approve' },
82
+ { to: 'retracted', event: 'decline' }],
83
+ superseded: [],
84
+ };
85
+ export function transitionStatus(db, a) {
86
+ const cur = db.prepare(`SELECT observation_id, root_id, entity_id, status FROM entity_observations WHERE observation_id = ?`).get(a.observationId);
87
+ if (!cur)
88
+ throw new Error(`observation ${a.observationId} not found`);
89
+ const allowed = (ALLOWED[cur.status] ?? []).find(x => x.event === a.event);
90
+ if (!allowed) {
91
+ throw new Error(`illegal transition: '${a.event}' from status '${cur.status}' is not in the §4.4 table` +
92
+ (cur.status === 'superseded'
93
+ ? " — 'superseded' is terminal; create a new revision instead"
94
+ : ''));
95
+ }
96
+ db.prepare(`UPDATE entity_observations SET status = ? WHERE observation_id = ?`)
97
+ .run(allowed.to, cur.observation_id);
98
+ recordEvent(db, { rootId: cur.root_id, event: a.event, fromId: cur.observation_id,
99
+ toId: cur.observation_id, reason: a.reason ?? null,
100
+ actor: a.actor ?? null, ts: a.ts });
101
+ }
@@ -0,0 +1,3 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare function rebuildProjection(db: Database.Database, entityId: string): void;
3
+ export declare function deleteStaleKgChunks(db: Database.Database, entityId: string): number;
@@ -0,0 +1,31 @@
1
+ // active 행을 projection_order 순으로 모아 entities.observations 를 재작성한다.
2
+ // 단계 1 에서는 이 배열이 여전히 FTS/벡터의 입력이다 — 그래서 projection 갱신만으로
3
+ // 기존 entity FTS 트리거와 벡터 무효화가 따라온다(spec §4.5).
4
+ export function rebuildProjection(db, entityId) {
5
+ const rows = db.prepare(`SELECT content FROM entity_observations
6
+ WHERE entity_id = ? AND status = 'active'
7
+ ORDER BY projection_order`).all(entityId);
8
+ db.prepare(`UPDATE entities SET observations = ? WHERE id = ?`)
9
+ .run(JSON.stringify(rows.map(r => r.content)), entityId);
10
+ }
11
+ // D4: observation 이 바뀌면 그 entity 를 가리키는 KG chunk 는 stale 이다.
12
+ // 단계 1 에서는 재생성하지 않고 fail-closed 로 제거한다 — 낡은 텍스트가
13
+ // hybridSearch 에 남아 있는 것이 없는 것보다 나쁘다.
14
+ //
15
+ // KG chunk 의 식별자는 document_id 가 아니라 (chunk_type, entity_id) 다:
16
+ // generateKnowledgeGraphChunks() 는 document_id 를 넣지 않는다.
17
+ //
18
+ // 이 경로는 현재 dormant 다 — generateKnowledgeGraphChunks/embedKnowledgeGraphChunks
19
+ // 는 MCP 도구로 노출되지 않고 내부 호출 지점도 없으며, 실사용 DB 의 chunk 는 전부
20
+ // chunk_type='document' 였다. 그래서 이것은 미래·타 배포 대비 방어층이고,
21
+ // 테스트는 KG chunk 를 직접 심어서 검증한다(자연 발생하지 않는다).
22
+ export function deleteStaleKgChunks(db, entityId) {
23
+ const chunks = db.prepare(`SELECT rowid FROM chunk_metadata WHERE chunk_type = 'entity' AND entity_id = ?`).all(entityId);
24
+ let n = 0;
25
+ for (const c of chunks) {
26
+ db.exec(`DELETE FROM chunks WHERE rowid = ${Number(c.rowid)}`);
27
+ db.prepare(`DELETE FROM chunk_metadata WHERE rowid = ?`).run(c.rowid);
28
+ n++;
29
+ }
30
+ return n;
31
+ }
@@ -0,0 +1 @@
1
+ export declare const OBSERVATION_SCHEMA_SQL = "\nCREATE TABLE IF NOT EXISTS observation_roots (\n root_id TEXT PRIMARY KEY NOT NULL,\n entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,\n projection_order INTEGER NOT NULL\n CHECK (typeof(projection_order) = 'integer' AND projection_order >= 0),\n created_at DATETIME NOT NULL,\n UNIQUE (entity_id, projection_order)\n);\n\nCREATE TABLE IF NOT EXISTS entity_observations (\n observation_id TEXT PRIMARY KEY NOT NULL,\n root_id TEXT NOT NULL REFERENCES observation_roots(root_id) ON DELETE CASCADE,\n entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,\n revision_no INTEGER NOT NULL\n CHECK (typeof(revision_no) = 'integer' AND revision_no >= 1),\n projection_order INTEGER NOT NULL\n CHECK (typeof(projection_order) = 'integer' AND projection_order >= 0),\n content TEXT NOT NULL,\n status TEXT NOT NULL CHECK (status IN\n ('active','superseded','retracted','provisional')),\n supersedes_id TEXT REFERENCES entity_observations(observation_id),\n recorded_at DATETIME NOT NULL,\n superseded_at DATETIME,\n UNIQUE (root_id, revision_no)\n);\n\nCREATE TABLE IF NOT EXISTS observation_sources (\n observation_id TEXT NOT NULL REFERENCES entity_observations(observation_id) ON DELETE CASCADE,\n source_kind TEXT NOT NULL CHECK (source_kind IN\n ('document','conversation','decision','import')),\n source_ref TEXT NOT NULL,\n source_hash TEXT,\n recorded_at DATETIME NOT NULL,\n PRIMARY KEY (observation_id, source_kind, source_ref)\n);\n\nCREATE TABLE IF NOT EXISTS observation_events (\n event_id TEXT PRIMARY KEY NOT NULL,\n root_id TEXT NOT NULL REFERENCES observation_roots(root_id) ON DELETE CASCADE,\n from_id TEXT,\n to_id TEXT,\n event TEXT NOT NULL CHECK (event IN\n ('add','correct','retract','restore','approve','decline','import')),\n change_kind TEXT CHECK (change_kind IN ('correction','world_change','retraction')),\n reason TEXT,\n actor TEXT,\n batch_id TEXT,\n recorded_at DATETIME NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_obs_active_per_root\n ON entity_observations(root_id) WHERE status = 'active';\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_obs_active_order\n ON entity_observations(entity_id, projection_order) WHERE status = 'active';\n\nCREATE INDEX IF NOT EXISTS idx_obs_entity ON entity_observations(entity_id);\nCREATE INDEX IF NOT EXISTS idx_obs_root ON entity_observations(root_id);\nCREATE INDEX IF NOT EXISTS idx_obs_events_root ON observation_events(root_id);\n\nCREATE TRIGGER IF NOT EXISTS trg_roots_immutable\nBEFORE UPDATE ON observation_roots\nBEGIN\n SELECT RAISE(ABORT, 'observation_roots is immutable');\nEND;\n\nCREATE TRIGGER IF NOT EXISTS trg_obs_content_immutable\nBEFORE UPDATE OF content ON entity_observations\nBEGIN\n SELECT RAISE(ABORT, 'observation content is immutable; create a new revision');\nEND;\n\nCREATE TRIGGER IF NOT EXISTS trg_obs_identity_immutable\nBEFORE UPDATE OF observation_id, root_id, entity_id, revision_no, supersedes_id,\n projection_order, recorded_at\nON entity_observations\nBEGIN\n SELECT RAISE(ABORT, 'identity/order fields are immutable after insert');\nEND;\n\nCREATE TRIGGER IF NOT EXISTS trg_obs_matches_root\nBEFORE INSERT ON entity_observations\nBEGIN\n SELECT RAISE(ABORT, 'root_id must exist and (entity_id, projection_order) must match it')\n WHERE NOT EXISTS (\n SELECT 1 FROM observation_roots r\n WHERE r.root_id = NEW.root_id\n AND r.entity_id = NEW.entity_id\n AND r.projection_order = NEW.projection_order);\nEND;\n\nCREATE TRIGGER IF NOT EXISTS trg_obs_chain_wellformed\nBEFORE INSERT ON entity_observations\nBEGIN\n SELECT RAISE(ABORT, 'revision_no must be >= 1') WHERE NEW.revision_no < 1;\n SELECT RAISE(ABORT, 'first revision must have NULL supersedes_id')\n WHERE NEW.revision_no = 1 AND NEW.supersedes_id IS NOT NULL;\n SELECT RAISE(ABORT, 'non-first revision must have a predecessor')\n WHERE NEW.revision_no > 1 AND NEW.supersedes_id IS NULL;\n SELECT RAISE(ABORT, 'supersedes must be the immediately preceding revision of the same root')\n WHERE NEW.supersedes_id IS NOT NULL AND NOT EXISTS (\n SELECT 1 FROM entity_observations p\n WHERE p.observation_id = NEW.supersedes_id\n AND p.root_id = NEW.root_id\n AND p.revision_no = NEW.revision_no - 1);\nEND;\n";
@@ -0,0 +1,115 @@
1
+ // v13 observation lifecycle schema (spec 2026-07-30 §4.1~4.3).
2
+ // 마이그레이션과 테스트가 이 상수를 공유한다 — 두 곳에 DDL 을 복제하면 갈라진다.
3
+ //
4
+ // SQLite 함정 3가지가 이 DDL 의 형태를 결정했다 (전부 실측):
5
+ // 1. INTEGER NOT NULL 은 타입 강제가 아니다 -> CHECK (typeof(x)='integer')
6
+ // 2. TEXT PRIMARY KEY 는 NULL 을 허용한다 -> NOT NULL 명시
7
+ // 3. BEFORE 트리거가 CHECK 보다 먼저 실행된다 -> 오류 문자열 순서가 정해진다
8
+ export const OBSERVATION_SCHEMA_SQL = `
9
+ CREATE TABLE IF NOT EXISTS observation_roots (
10
+ root_id TEXT PRIMARY KEY NOT NULL,
11
+ entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
12
+ projection_order INTEGER NOT NULL
13
+ CHECK (typeof(projection_order) = 'integer' AND projection_order >= 0),
14
+ created_at DATETIME NOT NULL,
15
+ UNIQUE (entity_id, projection_order)
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS entity_observations (
19
+ observation_id TEXT PRIMARY KEY NOT NULL,
20
+ root_id TEXT NOT NULL REFERENCES observation_roots(root_id) ON DELETE CASCADE,
21
+ entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
22
+ revision_no INTEGER NOT NULL
23
+ CHECK (typeof(revision_no) = 'integer' AND revision_no >= 1),
24
+ projection_order INTEGER NOT NULL
25
+ CHECK (typeof(projection_order) = 'integer' AND projection_order >= 0),
26
+ content TEXT NOT NULL,
27
+ status TEXT NOT NULL CHECK (status IN
28
+ ('active','superseded','retracted','provisional')),
29
+ supersedes_id TEXT REFERENCES entity_observations(observation_id),
30
+ recorded_at DATETIME NOT NULL,
31
+ superseded_at DATETIME,
32
+ UNIQUE (root_id, revision_no)
33
+ );
34
+
35
+ CREATE TABLE IF NOT EXISTS observation_sources (
36
+ observation_id TEXT NOT NULL REFERENCES entity_observations(observation_id) ON DELETE CASCADE,
37
+ source_kind TEXT NOT NULL CHECK (source_kind IN
38
+ ('document','conversation','decision','import')),
39
+ source_ref TEXT NOT NULL,
40
+ source_hash TEXT,
41
+ recorded_at DATETIME NOT NULL,
42
+ PRIMARY KEY (observation_id, source_kind, source_ref)
43
+ );
44
+
45
+ CREATE TABLE IF NOT EXISTS observation_events (
46
+ event_id TEXT PRIMARY KEY NOT NULL,
47
+ root_id TEXT NOT NULL REFERENCES observation_roots(root_id) ON DELETE CASCADE,
48
+ from_id TEXT,
49
+ to_id TEXT,
50
+ event TEXT NOT NULL CHECK (event IN
51
+ ('add','correct','retract','restore','approve','decline','import')),
52
+ change_kind TEXT CHECK (change_kind IN ('correction','world_change','retraction')),
53
+ reason TEXT,
54
+ actor TEXT,
55
+ batch_id TEXT,
56
+ recorded_at DATETIME NOT NULL
57
+ );
58
+
59
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_obs_active_per_root
60
+ ON entity_observations(root_id) WHERE status = 'active';
61
+
62
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_obs_active_order
63
+ ON entity_observations(entity_id, projection_order) WHERE status = 'active';
64
+
65
+ CREATE INDEX IF NOT EXISTS idx_obs_entity ON entity_observations(entity_id);
66
+ CREATE INDEX IF NOT EXISTS idx_obs_root ON entity_observations(root_id);
67
+ CREATE INDEX IF NOT EXISTS idx_obs_events_root ON observation_events(root_id);
68
+
69
+ CREATE TRIGGER IF NOT EXISTS trg_roots_immutable
70
+ BEFORE UPDATE ON observation_roots
71
+ BEGIN
72
+ SELECT RAISE(ABORT, 'observation_roots is immutable');
73
+ END;
74
+
75
+ CREATE TRIGGER IF NOT EXISTS trg_obs_content_immutable
76
+ BEFORE UPDATE OF content ON entity_observations
77
+ BEGIN
78
+ SELECT RAISE(ABORT, 'observation content is immutable; create a new revision');
79
+ END;
80
+
81
+ CREATE TRIGGER IF NOT EXISTS trg_obs_identity_immutable
82
+ BEFORE UPDATE OF observation_id, root_id, entity_id, revision_no, supersedes_id,
83
+ projection_order, recorded_at
84
+ ON entity_observations
85
+ BEGIN
86
+ SELECT RAISE(ABORT, 'identity/order fields are immutable after insert');
87
+ END;
88
+
89
+ CREATE TRIGGER IF NOT EXISTS trg_obs_matches_root
90
+ BEFORE INSERT ON entity_observations
91
+ BEGIN
92
+ SELECT RAISE(ABORT, 'root_id must exist and (entity_id, projection_order) must match it')
93
+ WHERE NOT EXISTS (
94
+ SELECT 1 FROM observation_roots r
95
+ WHERE r.root_id = NEW.root_id
96
+ AND r.entity_id = NEW.entity_id
97
+ AND r.projection_order = NEW.projection_order);
98
+ END;
99
+
100
+ CREATE TRIGGER IF NOT EXISTS trg_obs_chain_wellformed
101
+ BEFORE INSERT ON entity_observations
102
+ BEGIN
103
+ SELECT RAISE(ABORT, 'revision_no must be >= 1') WHERE NEW.revision_no < 1;
104
+ SELECT RAISE(ABORT, 'first revision must have NULL supersedes_id')
105
+ WHERE NEW.revision_no = 1 AND NEW.supersedes_id IS NOT NULL;
106
+ SELECT RAISE(ABORT, 'non-first revision must have a predecessor')
107
+ WHERE NEW.revision_no > 1 AND NEW.supersedes_id IS NULL;
108
+ SELECT RAISE(ABORT, 'supersedes must be the immediately preceding revision of the same root')
109
+ WHERE NEW.supersedes_id IS NOT NULL AND NOT EXISTS (
110
+ SELECT 1 FROM entity_observations p
111
+ WHERE p.observation_id = NEW.supersedes_id
112
+ AND p.root_id = NEW.root_id
113
+ AND p.revision_no = NEW.revision_no - 1);
114
+ END;
115
+ `;