@kanadego/dsh-heartbeat 1.7.4 → 1.9.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/README.md +52 -15
- package/client.js +93 -0
- package/config/policy.json +73 -64
- package/dist/{chunk-ON4MSU6E.js → chunk-2SYXKKZF.js} +286 -480
- package/dist/chunk-2SYXKKZF.js.map +1 -0
- package/dist/{chunk-4UE74TUB.js → chunk-63FUJLIF.js} +2 -2
- package/dist/{chunk-LLD7LUNN.js → chunk-IFTFDHZX.js} +2 -1
- package/dist/chunk-IPOZFZPY.js +177 -0
- package/dist/chunk-IPOZFZPY.js.map +1 -0
- package/dist/{chunk-SVP2NDRF.js → chunk-PQTSXW4F.js} +38 -9
- package/dist/chunk-PQTSXW4F.js.map +1 -0
- package/dist/chunk-RB3NLLGF.js +87 -0
- package/dist/chunk-RB3NLLGF.js.map +1 -0
- package/dist/chunk-VIZNIQLK.js +472 -0
- package/dist/chunk-VIZNIQLK.js.map +1 -0
- package/dist/cli/index.js +149 -15
- package/dist/cli/index.js.map +1 -1
- package/dist/{inbox-MMLHISQV.js → inbox-57NHQHY3.js} +3 -3
- package/dist/index.js +790 -209
- package/dist/index.js.map +1 -1
- package/dist/migrate-N6NXMZ3G.js +20 -0
- package/dist/{pool-YAIBE2L5.js → pool-7I4VACG2.js} +3 -3
- package/dist/pool-7I4VACG2.js.map +1 -0
- package/dist/snapshot-ZBCSX67Y.js +15 -0
- package/dist/snapshot-ZBCSX67Y.js.map +1 -0
- package/package.json +1 -1
- package/dist/chunk-ON4MSU6E.js.map +0 -1
- package/dist/chunk-SVP2NDRF.js.map +0 -1
- /package/dist/{chunk-4UE74TUB.js.map → chunk-63FUJLIF.js.map} +0 -0
- /package/dist/{chunk-LLD7LUNN.js.map → chunk-IFTFDHZX.js.map} +0 -0
- /package/dist/{inbox-MMLHISQV.js.map → inbox-57NHQHY3.js.map} +0 -0
- /package/dist/{pool-YAIBE2L5.js.map → migrate-N6NXMZ3G.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/seeds/pool.ts","../src/seeds/types.ts"],"sourcesContent":["// Material pool engine (design doc §4). All four eviction rules are\n// deterministic code - no LLM involvement (§4.2). Storage: data/seeds.jsonl,\n// one seed per JSON line, DPAPI-encrypted at rest (D14).\n\nimport path from 'node:path';\nimport type { PathGuard } from '../core/path-guard.js';\nimport { loadEncryptedText, saveEncryptedText } from '../vault/vault.js';\nimport type { Policy } from '../config/schema.js';\nimport {\n SEED_SOURCE_DEFAULT_CONFIDENCE,\n emptySeedDb,\n type Seed,\n type SeedCategory,\n type SeedDb,\n type SeedRetireReason,\n type SeedSource,\n type SeedTag,\n} from './types.js';\n\nconst DAY_MS = 86_400_000;\n\n// ── lifecycle audit (2026-10-06 / v1.9.0) ───────────────────────────────\n// The pool used to mutate in silence: a seed could appear, be evicted for\n// capacity, or be dropped from the archive with nothing in the log. Every\n// mutator now takes an optional sink and reports what it did. IDs and\n// metadata ONLY — heartbeat.jsonl is plaintext by charter and must never\n// carry conversation text; the seed text itself stays in the DPAPI pool.\nexport type SeedAuditFn = (entry: Record<string, unknown>) => void;\n\nconst NO_AUDIT: SeedAuditFn = () => {};\n\n/** Audit must never break the pool (or the beat that called it). */\nfunction emit(audit: SeedAuditFn, entry: Record<string, unknown>): void {\n try {\n audit(entry);\n } catch {\n /* audit is diagnostic; a failed append is not a pool failure */\n }\n}\n\n// Per-category pool caps (2026-09-18 spec ⑤): topic stock vs conversation-grown\n// chat material, decoupled from `source`; sum == the global maxActive default.\nexport const SEED_CATEGORY_CAPS: Record<SeedCategory, number> = { topic: 16, chat: 14 };\n\nconst CATEGORY_VALUES: readonly string[] = ['topic', 'chat'];\n\n/** Lenient category coercion for on-disk rows: anything missing/unknown (all\n * pre-v1.5 seeds) counts as topic stock. */\nexport function normalizeCategory(v: unknown): SeedCategory {\n return typeof v === 'string' && (CATEGORY_VALUES as readonly string[]).includes(v)\n ? (v as SeedCategory)\n : 'topic';\n}\n\nexport interface AddSeedInput {\n text: string;\n topic?: string;\n tag?: SeedTag;\n source?: SeedSource;\n /** Loop role (spec ⑤); defaults from source: chat->chat, everything else->topic. */\n category?: SeedCategory;\n confidence?: number;\n}\n\nexport interface AddSeedResult {\n kind: 'added' | 'merged' | 'duplicate';\n seed: Seed;\n evicted?: Seed;\n}\n\nexport interface GcReport {\n consumed: number;\n expired: number;\n coldBench: number;\n activeAfter: number;\n /** v1.9.0: archived rows dropped to honor seeds.archiveCap (oldest first). */\n archiveTrimmed: number;\n}\n\nexport const TTL_KEYS: readonly SeedTag[] = ['news', 'fandom', 'scene', 'promise'];\n\nexport function normalizeTag(tag: string | undefined): SeedTag {\n if (tag && (TTL_KEYS as readonly string[]).includes(tag)) return tag as SeedTag;\n return 'scene';\n}\n\nfunction parseIso(v: string): number {\n const t = Date.parse(v);\n return Number.isFinite(t) ? t : 0;\n}\n\n// ── storage ─────────────────────────────────────────────────────────────\n\nexport function seedsFilePath(dataDir: string): string {\n return path.join(dataDir, 'seeds.jsonl');\n}\n\nexport function loadPool(guard: PathGuard, file: string): SeedDb {\n const raw = loadEncryptedText(guard, file);\n if (raw === null) return emptySeedDb();\n const db = emptySeedDb();\n for (const line of raw.split('\\n')) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n try {\n const obj = JSON.parse(trimmed) as Seed;\n if (typeof obj.id === 'string' && obj.id.startsWith('s')) {\n // spec ⑤ loadPool tolerance: only the id is load-bearing; a missing or\n // unknown category (every pre-v1.5 row) falls back to topic stock.\n obj.category = normalizeCategory(obj.category);\n db.seeds.push(obj);\n const n = Number(obj.id.slice(1));\n if (Number.isFinite(n) && n > db.seq) db.seq = n;\n }\n } catch {\n // corrupt line: skip (pool is a cache; journal-level audit lives elsewhere)\n }\n }\n return db;\n}\n\nexport function savePool(guard: PathGuard, file: string, db: SeedDb): void {\n const body = db.seeds.map((s) => JSON.stringify(s)).join('\\n');\n saveEncryptedText(guard, file, body ? body + '\\n' : '');\n}\n\n// ── queries ─────────────────────────────────────────────────────────────\n\nexport const activeSeeds = (db: SeedDb): Seed[] => db.seeds.filter((s) => s.status === 'active');\nexport const archivedSeeds = (db: SeedDb): Seed[] => db.seeds.filter((s) => s.status === 'archived');\n\n// ── eviction scoring (rule 4) ───────────────────────────────────────────\n\n/**\n * Comprehensive score (§4.2): freshness x0.4 + unused x0.3 + confidence x0.3.\n * Higher = keep. Lowest-scoring active is evicted when the pool is full.\n */\nexport function evictionScore(seed: Seed, policy: Policy, now: number): number {\n const ttlDays = policy.seeds.ttlDays[seed.tag] || 14;\n const daysStale = Math.max(0, (now - parseIso(seed.lastEvidenceAt)) / DAY_MS);\n const freshness = Math.max(0, 1 - daysStale / ttlDays);\n const unused = seed.used === 0 ? 1 : 1 / seed.used;\n const w = policy.seeds.scoreWeights;\n return freshness * w.freshness + unused * w.unused + seed.confidence * w.confidence;\n}\n\n/** Pick the eviction victim: unprotected actives first (§4.2 加权保护); with\n * `category`, only that category's actives compete (spec ⑤ per-category caps). */\nexport function pickEvictionVictim(db: SeedDb, policy: Policy, now: number, category?: SeedCategory): Seed | null {\n const actives = activeSeeds(db).filter((s) => category === undefined || normalizeCategory(s.category) === category);\n if (actives.length === 0) return null;\n const unprotected = actives.filter((s) => !s.protected);\n const candidates = unprotected.length > 0 ? unprotected : actives;\n let worst: Seed | null = null;\n let worstScore = Number.POSITIVE_INFINITY;\n for (const s of candidates) {\n const score = evictionScore(s, policy, now);\n if (score < worstScore) {\n worstScore = score;\n worst = s;\n }\n }\n return worst;\n}\n\nfunction archiveSeed(seed: Seed, reason: SeedRetireReason, now: number): void {\n seed.status = 'archived';\n seed.retireReason = reason;\n seed.retiredAt = new Date(now).toISOString();\n}\n\n/** Spec ④: chat-grown material is consumed ONCE (conversation freshness);\n * every other category follows the policy-wide retireAfterUsed. */\nfunction retireLimit(s: Seed, policy: Policy): number {\n return normalizeCategory(s.category) === 'chat' ? 1 : policy.seeds.retireAfterUsed;\n}\n\n// ── operations ──────────────────────────────────────────────────────────\n\nexport function addSeed(\n guard: PathGuard,\n file: string,\n policy: Policy,\n input: AddSeedInput,\n now = Date.now(),\n audit: SeedAuditFn = NO_AUDIT,\n): AddSeedResult {\n const db = loadPool(guard, file);\n const text = input.text.trim();\n if (!text) throw new Error('seed text must not be empty');\n const tag = normalizeTag(input.tag);\n const source: SeedSource = input.source ?? 'chat';\n const category = normalizeCategory(input.category ?? (source === 'chat' ? 'chat' : 'topic'));\n const confidence = input.confidence ?? SEED_SOURCE_DEFAULT_CONFIDENCE[source];\n\n // Exact-text dedup against actives.\n const dup = activeSeeds(db).find((s) => s.text === text);\n if (dup) return { kind: 'duplicate', seed: dup };\n\n const nowIso = new Date(now).toISOString();\n const ttlMs = (policy.seeds.ttlDays[tag] || 14) * DAY_MS;\n\n // Same-topic merge (§4.3): collapse all same-topic actives + the new item.\n const sameTopic = activeSeeds(db).filter((s) => s.topic === (input.topic ?? text.slice(0, 24)));\n let seed: Seed;\n if (sameTopic.length > 0) {\n const kept = sameTopic[0]!;\n kept.text = text; // brief takes the latest\n kept.tag = tag;\n kept.source = source;\n kept.category = normalizeCategory(input.category ?? kept.category ?? category);\n kept.confidence = Math.max(kept.confidence, confidence);\n kept.used = sameTopic.reduce((acc, s) => acc + s.used, 0); // used accumulates\n kept.lastEvidenceAt = [kept.lastEvidenceAt, nowIso, ...sameTopic.map((s) => s.lastEvidenceAt)]\n .reduce((a, b) => (parseIso(b) > parseIso(a) ? b : a));\n kept.expiresAt = new Date(Math.max(parseIso(kept.expiresAt), now + ttlMs)).toISOString();\n kept.protected = kept.protected || source === 'hand' || (source === 'profile' && confidence >= 0.7);\n // remove the other same-topic actives (merged into kept)\n for (const extra of sameTopic.slice(1)) {\n extra.status = 'archived';\n extra.retireReason = 'completed';\n extra.retiredAt = nowIso;\n emit(audit, { event: 'seed_retired', id: extra.id, reason: 'completed', via: 'merge', into: kept.id });\n }\n seed = kept;\n // capacity still applies after growth check below if kept is somehow over cap\n if (activeSeeds(db).length > policy.seeds.maxActive) {\n const victim = pickEvictionVictim(db, policy, now, normalizeCategory(kept.category));\n if (victim && victim.id !== kept.id) {\n archiveSeed(victim, 'pool_cap', now);\n emit(audit, { event: 'seed_added', id: kept.id, kind: 'merged', tag: kept.tag, source: kept.source, category: kept.category, confidence: kept.confidence, mergedFrom: sameTopic.length - 1 });\n emit(audit, { event: 'seed_retired', id: victim.id, reason: 'pool_cap', via: 'merge', into: kept.id });\n savePool(guard, file, db);\n return { kind: 'merged', seed: kept, evicted: victim };\n }\n }\n emit(audit, { event: 'seed_added', id: kept.id, kind: 'merged', tag: kept.tag, source: kept.source, category: kept.category, confidence: kept.confidence, mergedFrom: sameTopic.length - 1 });\n savePool(guard, file, db);\n return { kind: 'merged', seed: kept };\n }\n\n // Capacity first (rules 4 + spec ⑤): per-category cap evicts within its own\n // pool; the global cap evicts in the incoming seed's category (a full topic\n // stock must not push chat material out and vice versa).\n let evicted: Seed | undefined;\n const catCount = activeSeeds(db).filter((s) => normalizeCategory(s.category) === category).length;\n if (catCount >= SEED_CATEGORY_CAPS[category]) {\n const victim = pickEvictionVictim(db, policy, now, category);\n if (victim) {\n archiveSeed(victim, 'pool_cap', now);\n evicted = victim;\n }\n }\n if (activeSeeds(db).length >= policy.seeds.maxActive) {\n const victim = pickEvictionVictim(db, policy, now, category);\n if (victim) {\n archiveSeed(victim, 'pool_cap', now);\n evicted = evicted ?? victim;\n }\n }\n\n db.seq += 1;\n seed = {\n id: `s${db.seq}`,\n text,\n topic: input.topic ?? text.slice(0, 24),\n tag,\n source,\n category,\n confidence,\n protected: source === 'hand' || (source === 'profile' && confidence >= 0.7),\n used: 0,\n bornAt: nowIso,\n expiresAt: new Date(now + ttlMs).toISOString(),\n lastUsedAt: null,\n lastEvidenceAt: nowIso,\n status: 'active',\n };\n db.seeds.push(seed);\n emit(audit, { event: 'seed_added', id: seed.id, kind: 'added', tag: seed.tag, source: seed.source, category: seed.category, confidence: seed.confidence });\n if (evicted) emit(audit, { event: 'seed_retired', id: evicted.id, reason: 'pool_cap', via: 'add', into: seed.id });\n savePool(guard, file, db);\n return { kind: 'added', seed, evicted };\n}\n\n/**\n * Deterministic gc (rules 1-3 of §4.2). Rule 4 (pool cap) fires on add only.\n * v1.9.0: the archive area is ALSO capped (seeds.archiveCap) — the archive is\n * a forensic trail, not a hoard; trim oldest-retired first.\n */\nexport function gcPool(guard: PathGuard, file: string, policy: Policy, now = Date.now(), audit: SeedAuditFn = NO_AUDIT): GcReport {\n const db = loadPool(guard, file);\n const report: GcReport = { consumed: 0, expired: 0, coldBench: 0, activeAfter: 0, archiveTrimmed: 0 };\n for (const s of activeSeeds(db)) {\n const ageMs = now - parseIso(s.bornAt);\n const sinceEvidence = parseIso(s.lastEvidenceAt);\n const sinceUsed = s.lastUsedAt ? parseIso(s.lastUsedAt) : 0;\n if (s.used >= retireLimit(s, policy) && sinceEvidence <= sinceUsed) {\n archiveSeed(s, 'consumed', now);\n report.consumed += 1;\n emit(audit, { event: 'seed_retired', id: s.id, reason: 'consumed', via: 'gc', used: s.used });\n } else if (now > parseIso(s.expiresAt)) {\n archiveSeed(s, 'expired', now);\n report.expired += 1;\n emit(audit, { event: 'seed_retired', id: s.id, reason: 'expired', via: 'gc', used: s.used });\n } else if (s.used === 0 && ageMs >= policy.seeds.coldBenchDays * DAY_MS) {\n archiveSeed(s, 'cold_bench', now);\n report.coldBench += 1;\n emit(audit, { event: 'seed_retired', id: s.id, reason: 'cold_bench', via: 'gc', used: s.used });\n }\n }\n // archive cap: drop the oldest-retired rows beyond the cap (retiredAt may be\n // missing on pre-v1.5 rows — parseIso('') = 0 sorts them oldest).\n const archived = db.seeds.filter((s) => s.status === 'archived');\n if (archived.length > policy.seeds.archiveCap) {\n const drop = [...archived]\n .sort((a, b) => parseIso(a.retiredAt ?? '') - parseIso(b.retiredAt ?? ''))\n .slice(0, archived.length - policy.seeds.archiveCap);\n const dropped = new Set(drop.map((s) => s.id));\n db.seeds = db.seeds.filter((s) => !dropped.has(s.id));\n report.archiveTrimmed = drop.length;\n // These rows are GONE (not archived — deleted): the log is the only trace\n // left, so name them. IDs only, and cap the list in case a first GC trims\n // a legacy archive wholesale.\n emit(audit, { event: 'seed_archive_trimmed', count: drop.length, ids: drop.slice(0, 50).map((s) => s.id) });\n }\n report.activeAfter = activeSeeds(db).length;\n savePool(guard, file, db);\n return report;\n}\n\n/** The seed surfaced in a real expression: count + maybe retire (rule 1). */\nexport function surfaceSeed(\n guard: PathGuard,\n file: string,\n policy: Policy,\n id: string,\n now = Date.now(),\n audit: SeedAuditFn = NO_AUDIT,\n): Seed | null {\n const db = loadPool(guard, file);\n const s = db.seeds.find((x) => x.id === id && x.status === 'active');\n if (!s) return null;\n s.used += 1;\n s.lastUsedAt = new Date(now).toISOString();\n if (s.used >= retireLimit(s, policy) && parseIso(s.lastEvidenceAt) <= parseIso(s.lastUsedAt)) {\n archiveSeed(s, 'consumed', now);\n emit(audit, { event: 'seed_retired', id: s.id, reason: 'consumed', via: 'surface', used: s.used });\n }\n savePool(guard, file, db);\n return s;\n}\n\n/** Deliberate retirement (item completed / no longer relevant). */\nexport function archiveSeedById(\n guard: PathGuard,\n file: string,\n id: string,\n reason: SeedRetireReason = 'completed',\n now = Date.now(),\n audit: SeedAuditFn = NO_AUDIT,\n): Seed | null {\n const db = loadPool(guard, file);\n const s = db.seeds.find((x) => x.id === id && x.status === 'active');\n if (!s) return null;\n archiveSeed(s, reason, now);\n emit(audit, { event: 'seed_retired', id: s.id, reason, via: 'manual' });\n savePool(guard, file, db);\n return s;\n}\n\n/** Restore an archived seed to the active pool (UI operation; resets TTL). */\nexport function restoreSeed(\n guard: PathGuard,\n file: string,\n policy: Policy,\n id: string,\n now = Date.now(),\n audit: SeedAuditFn = NO_AUDIT,\n): { ok: true; seed: Seed } | { ok: false; reason: string } {\n const db = loadPool(guard, file);\n const s = db.seeds.find((x) => x.id === id && x.status === 'archived');\n if (!s) return { ok: false, reason: 'archived seed not found' };\n if (activeSeeds(db).length >= policy.seeds.maxActive) {\n return { ok: false, reason: `pool full (${policy.seeds.maxActive}); archive something first` };\n }\n s.status = 'active';\n s.retireReason = undefined;\n s.retiredAt = undefined;\n s.expiresAt = new Date(now + (policy.seeds.ttlDays[s.tag] || 14) * DAY_MS).toISOString();\n s.lastEvidenceAt = new Date(now).toISOString();\n emit(audit, { event: 'seed_restored', id: s.id });\n savePool(guard, file, db);\n return { ok: true, seed: s };\n}\n\n/** Hard delete (UI explicit action with confirm; the caller audits it). */\nexport function deleteSeed(guard: PathGuard, file: string, id: string, audit: SeedAuditFn = NO_AUDIT): boolean {\n const db = loadPool(guard, file);\n const before = db.seeds.length;\n db.seeds = db.seeds.filter((x) => x.id !== id);\n if (db.seeds.length === before) return false;\n emit(audit, { event: 'seed_deleted', id });\n savePool(guard, file, db);\n return true;\n}\n","// Material pool (\"素材池\") types. Charter (axiom 3): this pool is a CACHE,\n// not memory - volatile, evictable, burnable; long-term memory only absorbs\n// conversation-validated content. Everything here may be destroyed.\n\nexport type SeedTag = 'news' | 'fandom' | 'scene' | 'promise';\nexport type SeedSource = 'chat' | 'screen' | 'browse' | 'hand' | 'profile';\nexport type SeedCategory = 'topic' | 'chat';\nexport type SeedRetireReason = 'consumed' | 'expired' | 'cold_bench' | 'pool_cap' | 'completed';\n\nexport interface Seed {\n id: string;\n /** Short brief shown to the reflection prompt. */\n text: string;\n /** Merge key (§4.3): same-topic actives collapse into one. */\n topic: string;\n tag: SeedTag;\n source: SeedSource;\n /** Loop role, decoupled from source (2026-09-18 spec ⑤): 'topic' = wander/\n * hand/profile stock (random pick), 'chat' = conversation-grown, consumed\n * once. Absent on pre-v1.5 rows; loadPool defaults them to 'topic'. */\n category?: SeedCategory;\n /** 0..1 trust level; handwriting and high-confidence profile sources gain eviction protection. */\n confidence: number;\n /** Eviction protection (§4.2 pool-cap rule): handwritten / high-confidence profile sources. */\n protected: boolean;\n /** Times this seed surfaced in an actual expression. */\n used: number;\n bornAt: string;\n /** bornAt + ttlDays[tag]; archived when passed (rule 2). */\n expiresAt: string;\n lastUsedAt: string | null;\n /** Fresh-evidence watermark: rule 1 spares seeds with evidence newer than last use. */\n lastEvidenceAt: string;\n status: 'active' | 'archived';\n retireReason?: SeedRetireReason;\n retiredAt?: string;\n}\n\nexport interface SeedDb {\n seq: number;\n seeds: Seed[];\n}\n\nexport function emptySeedDb(): SeedDb {\n return { seq: 0, seeds: [] };\n}\n\nexport const SEED_SOURCE_DEFAULT_CONFIDENCE: Record<SeedSource, number> = {\n hand: 1.0,\n profile: 0.7,\n chat: 0.6,\n screen: 0.4,\n browse: 0.4,\n};\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ARCHIVE_PREFIX,
|
|
3
|
+
appendAuditLine,
|
|
4
|
+
journalFilePath,
|
|
5
|
+
replayJournal,
|
|
6
|
+
snapshotFilePath
|
|
7
|
+
} from "./chunk-VIZNIQLK.js";
|
|
8
|
+
import {
|
|
9
|
+
saveJson
|
|
10
|
+
} from "./chunk-IFTFDHZX.js";
|
|
11
|
+
|
|
12
|
+
// src/profile/snapshot.ts
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import path from "path";
|
|
15
|
+
var SNAPSHOT_THRESHOLD = 5e3;
|
|
16
|
+
function snapshotDue(guard, dataDir, threshold = SNAPSHOT_THRESHOLD) {
|
|
17
|
+
let lines = 0;
|
|
18
|
+
try {
|
|
19
|
+
const raw = fs.readFileSync(guard.assert(journalFilePath(dataDir)), "utf8");
|
|
20
|
+
lines = raw.split("\n").filter((l) => l.trim()).length;
|
|
21
|
+
} catch {
|
|
22
|
+
return { needed: false, lines: 0 };
|
|
23
|
+
}
|
|
24
|
+
return { needed: lines >= threshold, lines };
|
|
25
|
+
}
|
|
26
|
+
function snapshotProfile(guard, dataDir, now = Date.now()) {
|
|
27
|
+
const journalFile = journalFilePath(dataDir);
|
|
28
|
+
let raw = "";
|
|
29
|
+
try {
|
|
30
|
+
raw = fs.readFileSync(guard.assert(journalFile), "utf8");
|
|
31
|
+
} catch {
|
|
32
|
+
return { ok: false, reason: "no journal" };
|
|
33
|
+
}
|
|
34
|
+
const liveRecords = raw.split("\n").filter((l) => l.trim());
|
|
35
|
+
if (liveRecords.length === 0) return { ok: false, reason: "journal empty, nothing to fold" };
|
|
36
|
+
const replayed = replayJournal(guard, dataDir);
|
|
37
|
+
const lastTs = lastRecordTs(raw) ?? new Date(now).toISOString();
|
|
38
|
+
const snapshot = {
|
|
39
|
+
version: 1,
|
|
40
|
+
baselineTs: lastTs,
|
|
41
|
+
recordsFolded: replayed.records,
|
|
42
|
+
doc: replayed.doc
|
|
43
|
+
};
|
|
44
|
+
saveJson(guard, snapshotFilePath(dataDir), snapshot);
|
|
45
|
+
const stamp = new Date(now).toISOString().replace(/[-:T]/g, "").slice(0, 15);
|
|
46
|
+
const archiveFile = `${ARCHIVE_PREFIX}${stamp}.jsonl`;
|
|
47
|
+
fs.writeFileSync(path.join(dataDir, archiveFile), liveRecords.join("\n") + "\n", "utf8");
|
|
48
|
+
fs.writeFileSync(guard.assert(journalFile), "", "utf8");
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
baselineTs: lastTs,
|
|
52
|
+
folded: replayed.records,
|
|
53
|
+
archiveFile,
|
|
54
|
+
liveRemaining: 0
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function lastRecordTs(raw) {
|
|
58
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
59
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
60
|
+
try {
|
|
61
|
+
return String(JSON.parse(lines[i]).ts ?? "") || null;
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function snapshotIfDue(guard, dataDir, auditFile, why, now = Date.now()) {
|
|
68
|
+
try {
|
|
69
|
+
const { needed, lines } = snapshotDue(guard, dataDir);
|
|
70
|
+
if (!needed) return;
|
|
71
|
+
const report = snapshotProfile(guard, dataDir, now);
|
|
72
|
+
appendAuditLine(auditFile, { event: "profile_snapshot", why, lines, ...report });
|
|
73
|
+
} catch (e) {
|
|
74
|
+
try {
|
|
75
|
+
appendAuditLine(auditFile, { event: "profile_snapshot_failed", why, error: String(e).slice(0, 160) });
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export {
|
|
82
|
+
SNAPSHOT_THRESHOLD,
|
|
83
|
+
snapshotDue,
|
|
84
|
+
snapshotProfile,
|
|
85
|
+
snapshotIfDue
|
|
86
|
+
};
|
|
87
|
+
//# sourceMappingURL=chunk-RB3NLLGF.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/profile/snapshot.ts"],"sourcesContent":["// Journal snapshot + shard rotation (v1.8.0, design doc §14 ③). The journal\n// grows without bound; past the threshold the maintenance phase folds it into\n// a DPAPI snapshot and moves the folded records into a timestamped archive\n// shard, so replay cost stays bounded while the raw history survives on disk.\n//\n// Safety: replayJournal (store.ts) is the single authority reader and already\n// understands snapshot → archives → live journal, so verify/rebuild keep\n// working across rotations with no special-casing.\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { appendAuditLine } from '../core/audit-log.js';\nimport type { PathGuard } from '../core/path-guard.js';\nimport { saveJson } from '../vault/vault.js';\nimport {\n journalFilePath,\n replayJournal,\n snapshotFilePath,\n ARCHIVE_PREFIX,\n type ProfileSnapshot,\n} from './store.js';\n\n/** Fold the journal once the live file reaches this many records. */\nexport const SNAPSHOT_THRESHOLD = 5000;\n\nexport interface SnapshotReport {\n ok: boolean;\n reason?: string;\n baselineTs?: string;\n folded?: number;\n archiveFile?: string;\n liveRemaining?: number;\n}\n\n/** How far the live journal is from the snapshot threshold. */\nexport function snapshotDue(guard: PathGuard, dataDir: string, threshold = SNAPSHOT_THRESHOLD): { needed: boolean; lines: number } {\n let lines = 0;\n try {\n const raw = fs.readFileSync(guard.assert(journalFilePath(dataDir)), 'utf8');\n lines = raw.split('\\n').filter((l) => l.trim()).length;\n } catch {\n return { needed: false, lines: 0 };\n }\n return { needed: lines >= threshold, lines };\n}\n\n/** Fold the live journal into a snapshot and shard the folded records into an\n * archive file. Returns a report; never throws on missing data (nothing to\n * fold is a no-op, reported as such). */\nexport function snapshotProfile(guard: PathGuard, dataDir: string, now = Date.now()): SnapshotReport {\n const journalFile = journalFilePath(dataDir);\n let raw = '';\n try {\n raw = fs.readFileSync(guard.assert(journalFile), 'utf8');\n } catch {\n return { ok: false, reason: 'no journal' };\n }\n const liveRecords = raw.split('\\n').filter((l) => l.trim());\n if (liveRecords.length === 0) return { ok: false, reason: 'journal empty, nothing to fold' };\n\n // Replay the CURRENT live journal only — prior snapshots + shards are\n // already inside the accumulated view; the live tail is what we fold.\n const replayed = replayJournal(guard, dataDir);\n const lastTs = lastRecordTs(raw) ?? new Date(now).toISOString();\n const snapshot: ProfileSnapshot = {\n version: 1,\n baselineTs: lastTs,\n recordsFolded: replayed.records,\n doc: replayed.doc,\n };\n saveJson(guard, snapshotFilePath(dataDir), snapshot);\n\n // Shard: move the folded records out of the live journal. The shard is a\n // closed file (never appended again); the live journal restarts empty. A\n // torn shard write is tolerated by replay (corrupt lines are skipped).\n const stamp = new Date(now).toISOString().replace(/[-:T]/g, '').slice(0, 15);\n const archiveFile = `${ARCHIVE_PREFIX}${stamp}.jsonl`;\n fs.writeFileSync(path.join(dataDir, archiveFile), liveRecords.join('\\n') + '\\n', 'utf8');\n fs.writeFileSync(guard.assert(journalFile), '', 'utf8');\n\n return {\n ok: true,\n baselineTs: lastTs,\n folded: replayed.records,\n archiveFile,\n liveRemaining: 0,\n };\n}\n\nfunction lastRecordTs(raw: string): string | null {\n const lines = raw.split('\\n').filter((l) => l.trim());\n for (let i = lines.length - 1; i >= 0; i--) {\n try {\n return String((JSON.parse(lines[i]!) as { ts?: string }).ts ?? '') || null;\n } catch { /* skip a torn tail line */ }\n }\n return null;\n}\n\n/** Maintenance hook: fold when over threshold. Audits + never throws. */\nexport function snapshotIfDue(guard: PathGuard, dataDir: string, auditFile: string, why: string, now = Date.now()): void {\n try {\n const { needed, lines } = snapshotDue(guard, dataDir);\n if (!needed) return;\n const report = snapshotProfile(guard, dataDir, now);\n appendAuditLine(auditFile, { event: 'profile_snapshot', why, lines, ...report });\n } catch (e) {\n try {\n appendAuditLine(auditFile, { event: 'profile_snapshot_failed', why, error: String(e).slice(0, 160) });\n } catch { /* audit must never break the beat */ }\n }\n}\n"],"mappings":";;;;;;;;;;;;AASA,OAAO,QAAQ;AACf,OAAO,UAAU;AAaV,IAAM,qBAAqB;AAY3B,SAAS,YAAY,OAAkB,SAAiB,YAAY,oBAAwD;AACjI,MAAI,QAAQ;AACZ,MAAI;AACF,UAAM,MAAM,GAAG,aAAa,MAAM,OAAO,gBAAgB,OAAO,CAAC,GAAG,MAAM;AAC1E,YAAQ,IAAI,MAAM,IAAI,EAAE,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE;AAAA,EAClD,QAAQ;AACN,WAAO,EAAE,QAAQ,OAAO,OAAO,EAAE;AAAA,EACnC;AACA,SAAO,EAAE,QAAQ,SAAS,WAAW,MAAM;AAC7C;AAKO,SAAS,gBAAgB,OAAkB,SAAiB,MAAM,KAAK,IAAI,GAAmB;AACnG,QAAM,cAAc,gBAAgB,OAAO;AAC3C,MAAI,MAAM;AACV,MAAI;AACF,UAAM,GAAG,aAAa,MAAM,OAAO,WAAW,GAAG,MAAM;AAAA,EACzD,QAAQ;AACN,WAAO,EAAE,IAAI,OAAO,QAAQ,aAAa;AAAA,EAC3C;AACA,QAAM,cAAc,IAAI,MAAM,IAAI,EAAE,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC;AAC1D,MAAI,YAAY,WAAW,EAAG,QAAO,EAAE,IAAI,OAAO,QAAQ,iCAAiC;AAI3F,QAAM,WAAW,cAAc,OAAO,OAAO;AAC7C,QAAM,SAAS,aAAa,GAAG,KAAK,IAAI,KAAK,GAAG,EAAE,YAAY;AAC9D,QAAM,WAA4B;AAAA,IAChC,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,eAAe,SAAS;AAAA,IACxB,KAAK,SAAS;AAAA,EAChB;AACA,WAAS,OAAO,iBAAiB,OAAO,GAAG,QAAQ;AAKnD,QAAM,QAAQ,IAAI,KAAK,GAAG,EAAE,YAAY,EAAE,QAAQ,UAAU,EAAE,EAAE,MAAM,GAAG,EAAE;AAC3E,QAAM,cAAc,GAAG,cAAc,GAAG,KAAK;AAC7C,KAAG,cAAc,KAAK,KAAK,SAAS,WAAW,GAAG,YAAY,KAAK,IAAI,IAAI,MAAM,MAAM;AACvF,KAAG,cAAc,MAAM,OAAO,WAAW,GAAG,IAAI,MAAM;AAEtD,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,YAAY;AAAA,IACZ,QAAQ,SAAS;AAAA,IACjB;AAAA,IACA,eAAe;AAAA,EACjB;AACF;AAEA,SAAS,aAAa,KAA4B;AAChD,QAAM,QAAQ,IAAI,MAAM,IAAI,EAAE,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC;AACpD,WAAS,IAAI,MAAM,SAAS,GAAG,KAAK,GAAG,KAAK;AAC1C,QAAI;AACF,aAAO,OAAQ,KAAK,MAAM,MAAM,CAAC,CAAE,EAAsB,MAAM,EAAE,KAAK;AAAA,IACxE,QAAQ;AAAA,IAA8B;AAAA,EACxC;AACA,SAAO;AACT;AAGO,SAAS,cAAc,OAAkB,SAAiB,WAAmB,KAAa,MAAM,KAAK,IAAI,GAAS;AACvH,MAAI;AACF,UAAM,EAAE,QAAQ,MAAM,IAAI,YAAY,OAAO,OAAO;AACpD,QAAI,CAAC,OAAQ;AACb,UAAM,SAAS,gBAAgB,OAAO,SAAS,GAAG;AAClD,oBAAgB,WAAW,EAAE,OAAO,oBAAoB,KAAK,OAAO,GAAG,OAAO,CAAC;AAAA,EACjF,SAAS,GAAG;AACV,QAAI;AACF,sBAAgB,WAAW,EAAE,OAAO,2BAA2B,KAAK,OAAO,OAAO,CAAC,EAAE,MAAM,GAAG,GAAG,EAAE,CAAC;AAAA,IACtG,QAAQ;AAAA,IAAwC;AAAA,EAClD;AACF;","names":[]}
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadJson,
|
|
3
|
+
readText,
|
|
4
|
+
saveJson,
|
|
5
|
+
writeText
|
|
6
|
+
} from "./chunk-IFTFDHZX.js";
|
|
7
|
+
|
|
8
|
+
// src/core/audit-log.ts
|
|
9
|
+
import fs2 from "fs";
|
|
10
|
+
import path2 from "path";
|
|
11
|
+
|
|
12
|
+
// src/core/atomic-fs.ts
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import { randomUUID, randomFillSync } from "crypto";
|
|
16
|
+
function tmpSibling(target, tag = "w") {
|
|
17
|
+
return path.join(
|
|
18
|
+
path.dirname(target),
|
|
19
|
+
`.${path.basename(target)}.${tag}-${randomUUID().slice(0, 8)}.tmp`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
function atomicWriteFileSync(target, data) {
|
|
23
|
+
const tmp = tmpSibling(target);
|
|
24
|
+
try {
|
|
25
|
+
fs.writeFileSync(tmp, data);
|
|
26
|
+
fs.renameSync(tmp, target);
|
|
27
|
+
} finally {
|
|
28
|
+
fs.rmSync(tmp, { force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function atomicWriteJsonSync(target, value) {
|
|
32
|
+
atomicWriteFileSync(target, JSON.stringify(value, null, 2));
|
|
33
|
+
}
|
|
34
|
+
function shredFileSync(target, passes = 3) {
|
|
35
|
+
const stat = fs.statSync(target);
|
|
36
|
+
if (!stat.isFile()) throw new Error(`shred: not a file: ${target}`);
|
|
37
|
+
const buf = Buffer.alloc(Math.max(stat.size, 1));
|
|
38
|
+
for (let i = 0; i < passes; i++) {
|
|
39
|
+
randomFillSync(buf);
|
|
40
|
+
fs.writeFileSync(target, buf);
|
|
41
|
+
}
|
|
42
|
+
fs.rmSync(target, { force: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/core/audit-log.ts
|
|
46
|
+
function appendAuditLine(file, event) {
|
|
47
|
+
fs2.mkdirSync(path2.dirname(file), { recursive: true });
|
|
48
|
+
const line = JSON.stringify({ ts: event.ts ?? (/* @__PURE__ */ new Date()).toISOString(), ...event });
|
|
49
|
+
fs2.appendFileSync(file, line + "\n", "utf8");
|
|
50
|
+
}
|
|
51
|
+
function readAuditLines(file) {
|
|
52
|
+
if (!fs2.existsSync(file)) return [];
|
|
53
|
+
const out = [];
|
|
54
|
+
const raw = fs2.readFileSync(file, "utf8");
|
|
55
|
+
for (const line of raw.split("\n")) {
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
if (!trimmed) continue;
|
|
58
|
+
try {
|
|
59
|
+
out.push(JSON.parse(trimmed));
|
|
60
|
+
} catch {
|
|
61
|
+
out.push({ ts: "", corrupt: true, raw: trimmed.slice(0, 200) });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function pruneAuditFile(file, maxAgeMs, now = Date.now()) {
|
|
67
|
+
if (!fs2.existsSync(file)) return 0;
|
|
68
|
+
const lines = readAuditLines(file);
|
|
69
|
+
const kept = lines.filter((e) => {
|
|
70
|
+
const ev = e;
|
|
71
|
+
const ts = Date.parse(ev.ts ?? "");
|
|
72
|
+
if (!Number.isFinite(ts)) return true;
|
|
73
|
+
return now - ts <= maxAgeMs;
|
|
74
|
+
});
|
|
75
|
+
const removed = lines.length - kept.length;
|
|
76
|
+
if (removed === 0) return 0;
|
|
77
|
+
const body = kept.map((e) => JSON.stringify(e)).join("\n");
|
|
78
|
+
atomicWriteFileSync(file, body ? body + "\n" : "");
|
|
79
|
+
return removed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/profile/store.ts
|
|
83
|
+
import path4 from "path";
|
|
84
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
85
|
+
import fs4 from "fs";
|
|
86
|
+
|
|
87
|
+
// src/profile/types.ts
|
|
88
|
+
var PARTITIONS = ["interest", "projects", "comm", "psy"];
|
|
89
|
+
var CONFIDENCE_CAP = {
|
|
90
|
+
chat: 0.6,
|
|
91
|
+
screen: 0.4,
|
|
92
|
+
browse: 0.4,
|
|
93
|
+
hand: 1,
|
|
94
|
+
ledger: 0.6
|
|
95
|
+
};
|
|
96
|
+
function emptyProfile() {
|
|
97
|
+
return {
|
|
98
|
+
version: 1,
|
|
99
|
+
partitions: { interest: { entries: [] }, projects: { entries: [] }, comm: { entries: [] }, psy: { entries: [] } }
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/profile/schema.ts
|
|
104
|
+
import fs3 from "fs";
|
|
105
|
+
import path3 from "path";
|
|
106
|
+
function loadProfileSchema(paths) {
|
|
107
|
+
const userPath = path3.join(paths.settingsDir, "profile-schema.json");
|
|
108
|
+
const file = fs3.existsSync(userPath) ? userPath : path3.join(paths.configDir, "profile-schema.json");
|
|
109
|
+
try {
|
|
110
|
+
const raw = JSON.parse(fs3.readFileSync(file, "utf8"));
|
|
111
|
+
if (!raw.partitions) throw new Error("partitions missing");
|
|
112
|
+
return raw;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
throw new Error(`profile-schema unreadable at ${file}: ${String(e)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function checkAddAgainstSchema(schema, partition, topic, subTopic, nominated) {
|
|
118
|
+
const p = schema.partitions[partition];
|
|
119
|
+
if (!p) return { ok: false, reason: `partition not in schema: ${partition}`, temporal: "stable" };
|
|
120
|
+
const t = p.topics[topic];
|
|
121
|
+
if (!t) return { ok: false, reason: `topic not in schema: ${partition}/${topic}`, temporal: "stable" };
|
|
122
|
+
const st = t.subtopics[subTopic];
|
|
123
|
+
if (!st) return { ok: false, reason: `sub_topic not in schema: ${partition}/${topic}/${subTopic}`, temporal: "stable" };
|
|
124
|
+
const allowed = st.allowed && st.allowed.length > 0 ? st.allowed : ["stable"];
|
|
125
|
+
const def = st.default && allowed.includes(st.default) ? st.default : allowed[0];
|
|
126
|
+
if (!nominated) return { ok: true, temporal: def };
|
|
127
|
+
if (!allowed.includes(nominated)) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
reason: `temporal "${nominated}" not allowed for ${partition}/${topic}/${subTopic} (allowed: ${allowed.join("|")})`,
|
|
131
|
+
temporal: def
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { ok: true, temporal: nominated };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/profile/store.ts
|
|
138
|
+
var DAY_MS = 864e5;
|
|
139
|
+
function profileFilePath(dataDir) {
|
|
140
|
+
return path4.join(dataDir, "profile.json");
|
|
141
|
+
}
|
|
142
|
+
function journalFilePath(dataDir) {
|
|
143
|
+
return path4.join(dataDir, "profile_journal.jsonl");
|
|
144
|
+
}
|
|
145
|
+
var SNAPSHOT_FILE = "profile_snapshot.json";
|
|
146
|
+
var ARCHIVE_PREFIX = "profile_journal.archive-";
|
|
147
|
+
function snapshotFilePath(dataDir) {
|
|
148
|
+
return path4.join(dataDir, SNAPSHOT_FILE);
|
|
149
|
+
}
|
|
150
|
+
function loadSnapshot(guard, dataDir) {
|
|
151
|
+
const snap = loadJson(guard, snapshotFilePath(dataDir));
|
|
152
|
+
if (!snap || snap.version !== 1 || typeof snap.baselineTs !== "string" || !snap.doc?.partitions) return null;
|
|
153
|
+
return snap;
|
|
154
|
+
}
|
|
155
|
+
function listArchiveFiles(guard, dataDir) {
|
|
156
|
+
try {
|
|
157
|
+
return fs4.readdirSync(guard.assert(dataDir)).filter((f) => f.startsWith(ARCHIVE_PREFIX) && f.endsWith(".jsonl")).sort();
|
|
158
|
+
} catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function replayText(doc, text, baselineTs, stats) {
|
|
163
|
+
const lines = text.split("\n");
|
|
164
|
+
for (let i = 0; i < lines.length; i++) {
|
|
165
|
+
const trimmed = lines[i].trim();
|
|
166
|
+
if (!trimmed) continue;
|
|
167
|
+
try {
|
|
168
|
+
const rec = JSON.parse(trimmed);
|
|
169
|
+
if (baselineTs && rec.ts && String(rec.ts) <= baselineTs) continue;
|
|
170
|
+
for (const op of rec.applied ?? []) applyOpPermissive(doc, op, rec.ts);
|
|
171
|
+
stats.records += 1;
|
|
172
|
+
} catch {
|
|
173
|
+
const isLast = lines.slice(i + 1).every((l) => !l.trim());
|
|
174
|
+
if (stats.isLive && isLast) {
|
|
175
|
+
stats.truncatedTail = lines.length - i;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function replayJournal(guard, dataDir) {
|
|
182
|
+
const snap = loadSnapshot(guard, dataDir);
|
|
183
|
+
const doc = snap ? snap.doc : emptyProfile();
|
|
184
|
+
const baselineTs = snap?.baselineTs ?? "";
|
|
185
|
+
const stats = { records: snap?.recordsFolded ?? 0, truncatedTail: 0, isLive: false };
|
|
186
|
+
for (const file of listArchiveFiles(guard, dataDir)) {
|
|
187
|
+
try {
|
|
188
|
+
replayText(doc, fs4.readFileSync(path4.join(dataDir, file), "utf8"), baselineTs, { records: 0, truncatedTail: 0, isLive: false });
|
|
189
|
+
} catch {
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
stats.isLive = true;
|
|
193
|
+
replayText(doc, readText(guard, journalFilePath(dataDir), ""), baselineTs, stats);
|
|
194
|
+
return { doc, truncatedTail: stats.truncatedTail, records: stats.records };
|
|
195
|
+
}
|
|
196
|
+
function loadProfile(guard, file) {
|
|
197
|
+
const doc = loadJson(guard, file);
|
|
198
|
+
if (!doc || !doc.partitions) return emptyProfile();
|
|
199
|
+
for (const p of PARTITIONS) {
|
|
200
|
+
if (!doc.partitions[p]) doc.partitions[p] = { entries: [] };
|
|
201
|
+
}
|
|
202
|
+
return doc;
|
|
203
|
+
}
|
|
204
|
+
function parseIso(v) {
|
|
205
|
+
const t = Date.parse(v);
|
|
206
|
+
return Number.isFinite(t) ? t : 0;
|
|
207
|
+
}
|
|
208
|
+
function refExists(guard, dataDir, ref) {
|
|
209
|
+
const base = ref.split("#")[0] ?? "";
|
|
210
|
+
if (!base) return false;
|
|
211
|
+
const target = path4.join(dataDir, base);
|
|
212
|
+
try {
|
|
213
|
+
return fs4.existsSync(guard.assert(target));
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function capForKinds(kinds) {
|
|
219
|
+
if (kinds.length === 0) return 0.4;
|
|
220
|
+
return Math.min(...kinds.map((k) => CONFIDENCE_CAP[k] ?? 0.4));
|
|
221
|
+
}
|
|
222
|
+
function findActive(doc, id) {
|
|
223
|
+
for (const p of PARTITIONS) {
|
|
224
|
+
const hit = doc.partitions[p].entries.find((e) => e.id === id && e.validTo === null);
|
|
225
|
+
if (hit) return hit;
|
|
226
|
+
}
|
|
227
|
+
return void 0;
|
|
228
|
+
}
|
|
229
|
+
function applyOpsToDoc(guard, dataDir, doc, ops, schema, policy, now) {
|
|
230
|
+
const applied = [];
|
|
231
|
+
const rejected = [];
|
|
232
|
+
const nowIso = new Date(now).toISOString();
|
|
233
|
+
for (const op of ops) {
|
|
234
|
+
if (op.op === "NOOP") {
|
|
235
|
+
applied.push(op);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (op.op === "ADD") {
|
|
239
|
+
if (op.partition === "psy" && !policy.profile.psyEnabled) {
|
|
240
|
+
rejected.push({ op, reason: "psy partition is disabled" });
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const check = checkAddAgainstSchema(schema, op.partition, op.topic, op.subTopic, op.temporal);
|
|
244
|
+
if (!check.ok) {
|
|
245
|
+
rejected.push({ op, reason: check.reason });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!op.evidence || op.evidence.length === 0) {
|
|
249
|
+
rejected.push({ op, reason: "ADD without evidence (no provenance, axiom 1)" });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const badRef = op.evidence.find((e) => !refExists(guard, dataDir, e.ref));
|
|
253
|
+
if (badRef) {
|
|
254
|
+
rejected.push({ op, reason: `evidence ref does not resolve: ${badRef.ref}` });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const cap = capForKinds(op.evidence.map((e) => e.kind));
|
|
258
|
+
const active = doc.partitions[op.partition].entries.filter((e) => e.validTo === null);
|
|
259
|
+
if (active.length >= policy.profile.partitionCap) {
|
|
260
|
+
rejected.push({ op, reason: `partition ${op.partition} at cap (${policy.profile.partitionCap}); converge first` });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
dbSeq += 1;
|
|
264
|
+
const entry = {
|
|
265
|
+
id: `p${dbSeq.toString(36)}${randomUUID2().slice(0, 4)}`,
|
|
266
|
+
partition: op.partition,
|
|
267
|
+
topic: op.topic,
|
|
268
|
+
subTopic: op.subTopic,
|
|
269
|
+
content: op.content.trim(),
|
|
270
|
+
confidence: Math.min(op.confidence ?? cap, cap),
|
|
271
|
+
temporal: check.temporal,
|
|
272
|
+
validFrom: nowIso,
|
|
273
|
+
validTo: null,
|
|
274
|
+
supersededBy: null,
|
|
275
|
+
evidence: op.evidence,
|
|
276
|
+
createdAt: nowIso,
|
|
277
|
+
updatedAt: nowIso,
|
|
278
|
+
updateCount: 0
|
|
279
|
+
};
|
|
280
|
+
op.assignedId = entry.id;
|
|
281
|
+
doc.partitions[op.partition].entries.push(entry);
|
|
282
|
+
applied.push(op);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (op.op === "UPDATE") {
|
|
286
|
+
const entry = findActive(doc, op.id);
|
|
287
|
+
if (!entry) {
|
|
288
|
+
rejected.push({ op, reason: `unknown or inactive entry: ${op.id}` });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (op.changes.content !== void 0) entry.content = op.changes.content.trim();
|
|
292
|
+
if (op.changes.confidence !== void 0) {
|
|
293
|
+
const cap = capForKinds(entry.evidence.map((e) => e.kind));
|
|
294
|
+
if (op.changes.confidence > entry.confidence && entry.evidence.length < 2) {
|
|
295
|
+
rejected.push({ op, reason: "confidence upgrade requires a second confirming observation" });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
entry.confidence = Math.min(op.changes.confidence, cap);
|
|
299
|
+
}
|
|
300
|
+
entry.updatedAt = nowIso;
|
|
301
|
+
entry.updateCount += 1;
|
|
302
|
+
applied.push(op);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (op.op === "INVALIDATE") {
|
|
306
|
+
const entry = findActive(doc, op.id);
|
|
307
|
+
if (!entry) {
|
|
308
|
+
rejected.push({ op, reason: `unknown or inactive entry: ${op.id}` });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (entry.temporal === "volatile") {
|
|
312
|
+
rejected.push({ op, reason: "volatile expiry is code-owned (time-driven), not LLM-nominated" });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const hasNewObservation = (op.evidence ?? []).length > 0 && (op.evidence ?? []).some((e) => parseIso(e.at) > parseIso(entry.evidence[entry.evidence.length - 1]?.at ?? ""));
|
|
316
|
+
if (!hasNewObservation) {
|
|
317
|
+
rejected.push({ op, reason: "stable INVALIDATE requires a newer contradicting observation" });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
entry.validTo = nowIso;
|
|
321
|
+
entry.supersededBy = null;
|
|
322
|
+
entry.updatedAt = nowIso;
|
|
323
|
+
entry.updateCount += 1;
|
|
324
|
+
if (op.evidence) entry.evidence.push(...op.evidence);
|
|
325
|
+
applied.push(op);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return { applied, rejected };
|
|
330
|
+
}
|
|
331
|
+
var dbSeq = 0;
|
|
332
|
+
function runDeterministicAging(doc, policy, now) {
|
|
333
|
+
const nowIso = new Date(now).toISOString();
|
|
334
|
+
let volatileExpired = 0;
|
|
335
|
+
let lowActivityMarked = 0;
|
|
336
|
+
for (const p of PARTITIONS) {
|
|
337
|
+
for (const e of doc.partitions[p].entries) {
|
|
338
|
+
if (e.validTo !== null) continue;
|
|
339
|
+
const lastEvidence = Math.max(...e.evidence.map((x) => parseIso(x.at)), parseIso(e.updatedAt));
|
|
340
|
+
if (e.temporal === "volatile") {
|
|
341
|
+
if (now - lastEvidence > policy.profile.volatileDays * DAY_MS) {
|
|
342
|
+
e.validTo = nowIso;
|
|
343
|
+
e.updatedAt = nowIso;
|
|
344
|
+
e.updateCount += 1;
|
|
345
|
+
volatileExpired += 1;
|
|
346
|
+
}
|
|
347
|
+
} else if (!e.lowActivity && now - lastEvidence > policy.profile.stableLowActivityDays * DAY_MS) {
|
|
348
|
+
e.lowActivity = true;
|
|
349
|
+
lowActivityMarked += 1;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { volatileExpired, lowActivityMarked };
|
|
354
|
+
}
|
|
355
|
+
function persistWithJournal(guard, dataDir, doc, record) {
|
|
356
|
+
saveJson(guard, profileFilePath(dataDir), doc);
|
|
357
|
+
const line = JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...record });
|
|
358
|
+
const journal = journalFilePath(dataDir);
|
|
359
|
+
try {
|
|
360
|
+
fs4.appendFileSync(guard.assert(journal), line + "\n", "utf8");
|
|
361
|
+
} catch {
|
|
362
|
+
fs4.mkdirSync(dataDir, { recursive: true });
|
|
363
|
+
fs4.appendFileSync(guard.assert(journal), line + "\n", "utf8");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function applyOpPermissive(doc, op, ts) {
|
|
367
|
+
if (op.op === "ADD") {
|
|
368
|
+
dbSeq += 1;
|
|
369
|
+
doc.partitions[op.partition].entries.push({
|
|
370
|
+
id: op.assignedId ?? `r${dbSeq.toString(36)}${randomUUID2().slice(0, 4)}`,
|
|
371
|
+
partition: op.partition,
|
|
372
|
+
topic: op.topic,
|
|
373
|
+
subTopic: op.subTopic,
|
|
374
|
+
content: op.content,
|
|
375
|
+
confidence: op.confidence ?? 0.5,
|
|
376
|
+
temporal: op.temporal ?? "stable",
|
|
377
|
+
validFrom: ts,
|
|
378
|
+
validTo: null,
|
|
379
|
+
supersededBy: null,
|
|
380
|
+
evidence: op.evidence,
|
|
381
|
+
createdAt: ts,
|
|
382
|
+
updatedAt: ts,
|
|
383
|
+
updateCount: 0
|
|
384
|
+
});
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (op.op === "UPDATE") {
|
|
388
|
+
const e = [...PARTITIONS].flatMap((p) => doc.partitions[p].entries).find((x) => x.id === op.id);
|
|
389
|
+
if (e) {
|
|
390
|
+
if (op.changes.content !== void 0) e.content = op.changes.content;
|
|
391
|
+
if (op.changes.confidence !== void 0) e.confidence = op.changes.confidence;
|
|
392
|
+
e.updatedAt = ts;
|
|
393
|
+
e.updateCount += 1;
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (op.op === "INVALIDATE") {
|
|
398
|
+
const e = [...PARTITIONS].flatMap((p) => doc.partitions[p].entries).find((x) => x.id === op.id);
|
|
399
|
+
if (e) {
|
|
400
|
+
e.validTo = ts;
|
|
401
|
+
e.updatedAt = ts;
|
|
402
|
+
e.updateCount += 1;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function verifyProfile(guard, dataDir) {
|
|
407
|
+
const replayed = replayJournal(guard, dataDir);
|
|
408
|
+
const onDisk = loadProfile(guard, profileFilePath(dataDir));
|
|
409
|
+
const strip = (doc) => JSON.stringify(doc.partitions, (k, v) => ["id", "supersededBy", "validFrom", "createdAt", "updatedAt", "retiredAt"].includes(k) ? "<norm>" : v);
|
|
410
|
+
const ok = strip(replayed.doc) === strip(onDisk);
|
|
411
|
+
if (ok) return { ok: true, truncatedTail: replayed.truncatedTail, records: replayed.records };
|
|
412
|
+
const diskIds = new Set([...PARTITIONS].flatMap((p) => onDisk.partitions[p].entries.map((e) => e.content)));
|
|
413
|
+
const replayIds = new Set([...PARTITIONS].flatMap((p) => replayed.doc.partitions[p].entries.map((e) => e.content)));
|
|
414
|
+
const onlyDisk = [...diskIds].find((c) => !replayIds.has(c));
|
|
415
|
+
const onlyReplay = [...replayIds].find((c) => !diskIds.has(c));
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
firstDivergence: {
|
|
419
|
+
id: onlyDisk ?? onlyReplay ?? "(content)",
|
|
420
|
+
expected: onlyReplay ? "absent in journal replay" : "present in journal replay",
|
|
421
|
+
actual: onlyDisk ? "present on disk" : "absent on disk"
|
|
422
|
+
},
|
|
423
|
+
truncatedTail: replayed.truncatedTail,
|
|
424
|
+
records: replayed.records
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function rebuildProfile(guard, dataDir, opts = {}) {
|
|
428
|
+
const replayed = replayJournal(guard, dataDir);
|
|
429
|
+
const target = profileFilePath(dataDir);
|
|
430
|
+
if (opts.check) {
|
|
431
|
+
const onDisk = loadProfile(guard, target);
|
|
432
|
+
const same = JSON.stringify(onDisk) === JSON.stringify(replayed.doc);
|
|
433
|
+
return {
|
|
434
|
+
ok: same,
|
|
435
|
+
truncatedTail: replayed.truncatedTail,
|
|
436
|
+
records: replayed.records,
|
|
437
|
+
wrote: false,
|
|
438
|
+
diffSummary: same ? "no diff" : "materialized view differs from journal replay"
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
saveJson(guard, target, replayed.doc);
|
|
442
|
+
if (replayed.truncatedTail > 0) {
|
|
443
|
+
writeText(
|
|
444
|
+
guard,
|
|
445
|
+
path4.join(dataDir, "logs", "rebuild-report.txt"),
|
|
446
|
+
`rebuild truncated ${replayed.truncatedTail} torn line(s) at journal tail; ${replayed.records} records applied
|
|
447
|
+
`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
return { ok: true, truncatedTail: replayed.truncatedTail, records: replayed.records, wrote: true };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export {
|
|
454
|
+
atomicWriteJsonSync,
|
|
455
|
+
shredFileSync,
|
|
456
|
+
appendAuditLine,
|
|
457
|
+
readAuditLines,
|
|
458
|
+
pruneAuditFile,
|
|
459
|
+
loadProfileSchema,
|
|
460
|
+
profileFilePath,
|
|
461
|
+
journalFilePath,
|
|
462
|
+
ARCHIVE_PREFIX,
|
|
463
|
+
snapshotFilePath,
|
|
464
|
+
replayJournal,
|
|
465
|
+
loadProfile,
|
|
466
|
+
applyOpsToDoc,
|
|
467
|
+
runDeterministicAging,
|
|
468
|
+
persistWithJournal,
|
|
469
|
+
verifyProfile,
|
|
470
|
+
rebuildProfile
|
|
471
|
+
};
|
|
472
|
+
//# sourceMappingURL=chunk-VIZNIQLK.js.map
|