@ngockhoale/ukit 3.0.8 → 3.0.9
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/CHANGELOG.md +14 -1
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/scripts/audit/decision-coverage.mjs +29 -2
- package/scripts/bench/data-foundation.mjs +52 -3
- package/scripts/bench/decision-runtime-baseline.mjs +427 -0
- package/scripts/bench/decision-runtime-metrics.mjs +67 -0
- package/scripts/bench/decision-runtime-variant.mjs +626 -0
- package/scripts/bench/memory-ablation.mjs +495 -0
- package/scripts/bench/memory-baseline.mjs +596 -0
- package/scripts/bench/memory-bench.mjs +661 -0
- package/scripts/bench/memory-canary.mjs +321 -0
- package/scripts/bench/memory-corpus.mjs +354 -0
- package/scripts/bench/memory-gate.mjs +389 -0
- package/scripts/bench/memory-metrics.mjs +179 -0
- package/scripts/bench/parallel-agents.mjs +33 -11
- package/scripts/bench/recorder-overhead.mjs +204 -0
- package/scripts/bench/sqlite-spike.mjs +451 -0
- package/scripts/measure-decision-gateway.mjs +306 -0
- package/scripts/perf/audit-perf.mjs +35 -17
- package/src/bug/triageBug.js +4 -3
- package/src/cli/commands/memory.js +357 -63
- package/src/context/detectProjectContext.js +11 -1
- package/src/core/agentRuntime/adapters.js +254 -0
- package/src/core/agentRuntime/artifacts.js +192 -0
- package/src/core/agentRuntime/completionGate.js +176 -0
- package/src/core/agentRuntime/context.js +149 -0
- package/src/core/agentRuntime/contract.js +247 -0
- package/src/core/agentRuntime/diagnostics.js +244 -0
- package/src/core/agentRuntime/evaluation.js +163 -0
- package/src/core/agentRuntime/eventStore.js +404 -0
- package/src/core/agentRuntime/liveness.js +60 -0
- package/src/core/agentRuntime/planCompiler.js +322 -0
- package/src/core/agentRuntime/promotion.js +53 -0
- package/src/core/agentRuntime/qualityComparison.js +112 -0
- package/src/core/agentRuntime/recovery.js +266 -0
- package/src/core/agentRuntime/resourcePolicy.js +78 -0
- package/src/core/agentRuntime/runtimeSupport.js +237 -0
- package/src/core/agentRuntime/supervisor.js +565 -0
- package/src/core/agentRuntime/vmEngine.js +621 -0
- package/src/core/codeintel/analogy.js +3 -2
- package/src/core/experiments/dynamicWorkflow.js +17 -2
- package/src/core/fileOps.js +21 -3
- package/src/core/memory/deltaOverlays.js +75 -30
- package/src/core/memory/learningCandidates.js +93 -48
- package/src/core/memory/memoryFlags.js +83 -0
- package/src/core/memory/memoryFreshness.js +190 -0
- package/src/core/memory/memoryHit.js +144 -0
- package/src/core/memory/migrate.js +69 -189
- package/src/core/memory/migrateMapping.js +232 -0
- package/src/core/memory/mutateMemory.js +323 -0
- package/src/core/memory/policy.js +96 -0
- package/src/core/memory/projectIdentity.js +266 -0
- package/src/core/memory/recordIndex.js +178 -0
- package/src/core/memory/recordStore.js +133 -20
- package/src/core/memory/records.js +144 -6
- package/src/core/memory/retrieval.js +259 -125
- package/src/core/memory/store.js +16 -5
- package/src/core/memory/storeBackup.js +226 -0
- package/src/core/memory/storeV2.js +63 -26
- package/src/core/memory/storeV2Loader.js +30 -12
- package/src/core/memory/userMemory.js +38 -20
- package/src/core/memory/writeClassification.js +161 -0
- package/src/core/memory/writeGuard.js +129 -0
- package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
- package/src/core/observability/analytics/cohorts.js +148 -0
- package/src/core/observability/analytics/storeDigest.js +163 -0
- package/src/core/observability/evaluation/experimentPlan.js +95 -0
- package/src/core/observability/evaluation/findings.js +99 -0
- package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
- package/src/core/observability/evaluation/perturbation.js +273 -0
- package/src/core/observability/evaluation/replay.js +7 -1
- package/src/core/observability/evaluation/scorecard.js +23 -3
- package/src/core/observability/rollout.js +11 -7
- package/src/core/observability/schema/compatibility.js +135 -0
- package/src/core/observability/schema/registry.js +99 -0
- package/src/core/observability/schema/validate.js +7 -0
- package/src/core/observability/support/import.js +53 -9
- package/src/core/observability/support/paths.js +13 -3
- package/src/core/observability/support/projector.js +148 -12
- package/src/core/output/index.js +12 -2
- package/src/core/runtimeConfig.js +83 -0
- package/src/core/runtimePaths.js +3 -0
- package/src/core/sensitiveValueScanner.js +40 -0
- package/src/core/token/index.js +40 -3
- package/src/decision/client.js +37 -13
- package/src/decision/protocol.js +1 -1
- package/src/decision/registry.js +5 -3
- package/src/decision/runtimeDecide.js +242 -0
- package/src/decision/runtimeFilter.js +150 -0
- package/src/decision/runtimeScheduler.js +239 -0
- package/src/index/buildIndex.js +13 -12
- package/src/index/queryIndex.js +35 -14
- package/src/index/relatedTests.js +50 -8
- package/src/index/resolveContext.js +9 -4
- package/src/manifest/selectItems.js +7 -3
- package/src/render/instructionRenderer.js +17 -5
- package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
- package/template_project/.claude/ukit/index/route-task.mjs +121 -19
- package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
- package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
- package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
- package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
- package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +145 -14
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Pure v1→v2 record mapping — SPEC §5 FR-001/FR-003 (M04-01).
|
|
2
|
+
// mapLegacySources(sources, opts) → { records, plan[], skipped }
|
|
3
|
+
// sources: collectLegacySources shape
|
|
4
|
+
// { user, userFile?, projects:[{id,file?,content}], sessions:[{file?,session}|session],
|
|
5
|
+
// corruptFiles, corrupt?:[fileName] }
|
|
6
|
+
// opts: { projectId, now, episodeTtlMs?, tombstones?, resolveProjectId? }
|
|
7
|
+
// Deterministic: record ids are `v1-<sha1(sourceKind:type:normalizedText)>` —
|
|
8
|
+
// stable across runs and legacy file renames. No I/O; `now` is injected.
|
|
9
|
+
// Rules (FR-003): tombstone precedence → plan 'tombstoned'; alias collision
|
|
10
|
+
// (resolveProjectId returns a different verified identity) → plan 'conflict';
|
|
11
|
+
// pending candidates → trust_tier 'candidate' + meta.legacyStatus 'pending',
|
|
12
|
+
// never status 'active'; everything else → trust_tier 'legacy-unverified'.
|
|
13
|
+
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import { createRecord } from './records.js';
|
|
16
|
+
import { isTombstoned } from './recordStore.js';
|
|
17
|
+
|
|
18
|
+
function normalizeText(text) {
|
|
19
|
+
return String(text).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fingerprint(type, text) {
|
|
23
|
+
return crypto.createHash('sha1').update(`v1:${type}:${normalizeText(text)}`).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function recordId(sourceKind, type, text) {
|
|
27
|
+
return `v1-${crypto.createHash('sha1')
|
|
28
|
+
.update(`${sourceKind}:${type}:${normalizeText(text)}`)
|
|
29
|
+
.digest('hex')}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sessionText(session) {
|
|
33
|
+
return [
|
|
34
|
+
session.taskDescription,
|
|
35
|
+
session.outcome ? `outcome: ${session.outcome}` : null,
|
|
36
|
+
...(Array.isArray(session.keyActions) ? session.keyActions : []),
|
|
37
|
+
].filter(Boolean).join(' — ');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* mapLegacySources(sources, { projectId, now, episodeTtlMs, tombstones,
|
|
42
|
+
* resolveProjectId }) → { records, plan, skipped }
|
|
43
|
+
*/
|
|
44
|
+
export function mapLegacySources(sources, {
|
|
45
|
+
projectId = null,
|
|
46
|
+
now = null,
|
|
47
|
+
episodeTtlMs = 90 * 24 * 60 * 60 * 1000,
|
|
48
|
+
tombstones = [],
|
|
49
|
+
resolveProjectId = null,
|
|
50
|
+
} = {}) {
|
|
51
|
+
const createdNow = Number.isFinite(now) ? now : Date.now();
|
|
52
|
+
const doc = { tombstones: Array.isArray(tombstones) ? tombstones : [] };
|
|
53
|
+
const records = [];
|
|
54
|
+
const plan = [];
|
|
55
|
+
let skipped = 0;
|
|
56
|
+
|
|
57
|
+
const emit = (spec, source, sourceKind) => {
|
|
58
|
+
const id = recordId(sourceKind, spec.type, spec.text);
|
|
59
|
+
const fp = fingerprint(spec.type, spec.text);
|
|
60
|
+
if (isTombstoned(doc, { id, fingerprint: fp })) {
|
|
61
|
+
plan.push({ id, source, action: 'tombstoned', reason: 'tombstone-precedence' });
|
|
62
|
+
skipped += 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let record;
|
|
66
|
+
try {
|
|
67
|
+
record = createRecord({
|
|
68
|
+
id,
|
|
69
|
+
type: spec.type,
|
|
70
|
+
scope: spec.scope,
|
|
71
|
+
text: spec.text,
|
|
72
|
+
provenance: spec.provenance,
|
|
73
|
+
sourceFingerprint: fp,
|
|
74
|
+
confidence: spec.confidence,
|
|
75
|
+
createdBy: 'migration',
|
|
76
|
+
projectId: spec.projectId ?? null,
|
|
77
|
+
validUntil: spec.validUntil ?? null,
|
|
78
|
+
meta: spec.meta ?? {},
|
|
79
|
+
trustTier: spec.trustTier ?? 'legacy-unverified',
|
|
80
|
+
reason: 'migration',
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
plan.push({ id, source, action: 'skip', reason: 'invalid-record' });
|
|
84
|
+
skipped += 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (Number.isFinite(spec.createdAt)) record.created_at = spec.createdAt;
|
|
88
|
+
else record.created_at = createdNow;
|
|
89
|
+
if (spec.status) record.status = spec.status;
|
|
90
|
+
records.push(record);
|
|
91
|
+
plan.push({ id, source, action: 'add', reason: 'mapped' });
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ── user memory ──
|
|
95
|
+
const user = sources?.user;
|
|
96
|
+
const userFile = sources?.userFile ?? 'user.json';
|
|
97
|
+
if (user && typeof user === 'object') {
|
|
98
|
+
for (const rule of Array.isArray(user.rules) ? user.rules : []) {
|
|
99
|
+
if (typeof rule !== 'string' || !rule) continue;
|
|
100
|
+
emit({
|
|
101
|
+
type: 'project_rule', scope: 'user', text: rule,
|
|
102
|
+
confidence: 1.0, provenance: 'legacy:user-rules', createdAt: user.updatedAt,
|
|
103
|
+
}, userFile, 'user-rules');
|
|
104
|
+
}
|
|
105
|
+
const prefs = user.preferences && typeof user.preferences === 'object' ? user.preferences : {};
|
|
106
|
+
for (const [key, value] of Object.entries(prefs)) {
|
|
107
|
+
const text = `${key} = ${typeof value === 'string' ? value : JSON.stringify(value)}`;
|
|
108
|
+
emit({
|
|
109
|
+
type: 'derived_fact', scope: 'user', text,
|
|
110
|
+
confidence: 1.0, provenance: 'legacy:user-preferences', createdAt: user.updatedAt,
|
|
111
|
+
}, userFile, 'user-preferences');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── project memory ──
|
|
116
|
+
for (const entry of Array.isArray(sources?.projects) ? sources.projects : []) {
|
|
117
|
+
const content = entry?.content ?? entry;
|
|
118
|
+
const legacyId = entry?.id ?? content?.id ?? null;
|
|
119
|
+
const file = entry?.file ?? `${legacyId ?? 'project'}.json`;
|
|
120
|
+
if (!content || typeof content !== 'object') continue;
|
|
121
|
+
|
|
122
|
+
// Alias collision: a legacy id that resolves to a DIFFERENT verified
|
|
123
|
+
// identity is a conflict — never silently rebound to this project.
|
|
124
|
+
if (typeof resolveProjectId === 'function' && legacyId) {
|
|
125
|
+
const resolved = resolveProjectId(legacyId);
|
|
126
|
+
if (resolved && resolved !== legacyId) {
|
|
127
|
+
plan.push({
|
|
128
|
+
id: `v1-${crypto.createHash('sha1').update(`project:${legacyId}`).digest('hex')}`,
|
|
129
|
+
source: file,
|
|
130
|
+
action: 'conflict',
|
|
131
|
+
reason: `alias-collision:${resolved}`,
|
|
132
|
+
});
|
|
133
|
+
skipped += 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const pid = legacyId ?? projectId;
|
|
138
|
+
const createdAt = content.updatedAt;
|
|
139
|
+
|
|
140
|
+
for (const text of Array.isArray(content.conventions) ? content.conventions : []) {
|
|
141
|
+
if (typeof text !== 'string' || !text) continue;
|
|
142
|
+
emit({
|
|
143
|
+
type: 'project_rule', scope: 'repo', text,
|
|
144
|
+
confidence: 0.9, provenance: 'legacy:conventions', projectId: pid, createdAt,
|
|
145
|
+
}, file, 'conventions');
|
|
146
|
+
}
|
|
147
|
+
for (const text of Array.isArray(content.activeRules) ? content.activeRules : []) {
|
|
148
|
+
if (typeof text !== 'string' || !text) continue;
|
|
149
|
+
emit({
|
|
150
|
+
type: 'project_rule', scope: 'repo', text,
|
|
151
|
+
confidence: 1.0, provenance: 'legacy:activeRules', projectId: pid, createdAt,
|
|
152
|
+
}, file, 'activeRules');
|
|
153
|
+
}
|
|
154
|
+
for (const decision of Array.isArray(content.decisions) ? content.decisions : []) {
|
|
155
|
+
if (!decision || typeof decision !== 'object') continue;
|
|
156
|
+
const text = decision.why ? `${decision.what} — ${decision.why}` : String(decision.what ?? '');
|
|
157
|
+
if (!text) continue;
|
|
158
|
+
emit({
|
|
159
|
+
type: 'derived_fact', scope: 'repo', text,
|
|
160
|
+
confidence: 0.9, provenance: 'legacy:decisions', projectId: pid,
|
|
161
|
+
createdAt: decision.when ?? createdAt,
|
|
162
|
+
}, file, 'decisions');
|
|
163
|
+
}
|
|
164
|
+
const profileEntries = [
|
|
165
|
+
...(typeof content.architecture === 'string' && content.architecture ? [content.architecture] : []),
|
|
166
|
+
...(Array.isArray(content.techStack)
|
|
167
|
+
? content.techStack.filter((t) => typeof t === 'string' && t) : []),
|
|
168
|
+
];
|
|
169
|
+
for (const text of profileEntries) {
|
|
170
|
+
emit({
|
|
171
|
+
type: 'derived_fact', scope: 'repo', text,
|
|
172
|
+
confidence: 0.8, provenance: 'legacy:project-profile', projectId: pid, createdAt,
|
|
173
|
+
}, file, 'project-profile');
|
|
174
|
+
}
|
|
175
|
+
for (const pc of Array.isArray(content.patternCandidates) ? content.patternCandidates : []) {
|
|
176
|
+
if (!pc || typeof pc !== 'object' || typeof pc.text !== 'string' || !pc.text) continue;
|
|
177
|
+
const legacyStatus = pc.status ?? 'pending';
|
|
178
|
+
const meta = { ...pc, legacyStatus };
|
|
179
|
+
const base = {
|
|
180
|
+
text: pc.text, provenance: 'legacy:pattern-candidate', projectId: pid,
|
|
181
|
+
createdAt: pc.detectedAt ?? createdAt, meta,
|
|
182
|
+
};
|
|
183
|
+
if (legacyStatus === 'approved') {
|
|
184
|
+
emit({ ...base, type: 'project_rule', scope: 'repo', confidence: 0.9 }, file, 'pattern-candidate');
|
|
185
|
+
} else if (legacyStatus === 'rejected') {
|
|
186
|
+
emit({ ...base, type: 'derived_fact', scope: 'task', confidence: 0.4, status: 'archived' },
|
|
187
|
+
file, 'pattern-candidate');
|
|
188
|
+
} else {
|
|
189
|
+
// Candidate isolation: pending candidates keep candidate trust and
|
|
190
|
+
// never land as active records.
|
|
191
|
+
emit({
|
|
192
|
+
...base, type: 'derived_fact', scope: 'task', confidence: 0.4,
|
|
193
|
+
status: 'stale', trustTier: 'candidate',
|
|
194
|
+
}, file, 'pattern-candidate');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── sessions ──
|
|
200
|
+
for (const entry of Array.isArray(sources?.sessions) ? sources.sessions : []) {
|
|
201
|
+
const session = entry?.session ?? entry;
|
|
202
|
+
const file = entry?.file ?? 'sessions';
|
|
203
|
+
if (!session || typeof session !== 'object') continue;
|
|
204
|
+
const text = sessionText(session);
|
|
205
|
+
if (!text) continue;
|
|
206
|
+
const createdAt = session.startedAt ?? session.endedAt ?? null;
|
|
207
|
+
emit({
|
|
208
|
+
type: 'episode', scope: 'session', text,
|
|
209
|
+
confidence: 0.7, provenance: 'legacy:session',
|
|
210
|
+
projectId: typeof session.projectId === 'string' ? session.projectId : projectId,
|
|
211
|
+
createdAt,
|
|
212
|
+
validUntil: createdAt != null ? createdAt + episodeTtlMs : null,
|
|
213
|
+
meta: { legacyId: session.id ?? null },
|
|
214
|
+
}, file, 'session');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── corrupt legacy files: counted, never treated as empty ──
|
|
218
|
+
const corruptCount = Number.isFinite(sources?.corruptFiles) ? sources.corruptFiles : 0;
|
|
219
|
+
const corruptNames = Array.isArray(sources?.corrupt) ? sources.corrupt : [];
|
|
220
|
+
for (let i = 0; i < corruptCount; i += 1) {
|
|
221
|
+
const name = corruptNames[i] ?? `corrupt-file-${i + 1}`;
|
|
222
|
+
plan.push({
|
|
223
|
+
id: `v1-${crypto.createHash('sha1').update(`corrupt:${name}`).digest('hex')}`,
|
|
224
|
+
source: name,
|
|
225
|
+
action: 'skip',
|
|
226
|
+
reason: 'corrupt-legacy-file',
|
|
227
|
+
});
|
|
228
|
+
skipped += 1;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { records, plan, skipped };
|
|
232
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// mutateMemory — single guarded write entry for project + user stores
|
|
2
|
+
// (SPEC §5 FR-011..FR-015). Every public write (add/update/archive/forget/
|
|
3
|
+
// purge/restore) funnels through here so tombstone checks, optimistic
|
|
4
|
+
// version checks, write-guard rejection, and the idempotency journal run
|
|
5
|
+
// inside the same locked read-modify-write — no unguarded bypass remains.
|
|
6
|
+
//
|
|
7
|
+
// Domain failures are typed results, never throws. Lock-timeout and
|
|
8
|
+
// unexpected I/O errors still propagate per the mutateRecordStore
|
|
9
|
+
// fail-closed contract; StoreCorruptError is caught and mapped to
|
|
10
|
+
// {status:'corrupt'} (quarantine already happened inside the lock).
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { readJsonIfExists, writeJson } from '../fileOps.js';
|
|
16
|
+
import { buildRuntimePaths } from '../runtimePaths.js';
|
|
17
|
+
import { buildUserPaths } from '../userPaths.js';
|
|
18
|
+
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
19
|
+
import {
|
|
20
|
+
mutateRecordStore,
|
|
21
|
+
isTombstoned,
|
|
22
|
+
applyRecordPatch,
|
|
23
|
+
isRawRecord,
|
|
24
|
+
StoreCorruptError,
|
|
25
|
+
} from './recordStore.js';
|
|
26
|
+
import { createRecord, normalizeRecord } from './records.js';
|
|
27
|
+
import { guardWritePayload, redactWritePayload } from './writeGuard.js';
|
|
28
|
+
import { classifyWrite, mergeEvidence, REVIEW_QUEUE_MAX } from './writeClassification.js';
|
|
29
|
+
import { resolveMemoryStage, appendRolloutReceipt, latencyBandForMs } from './memoryFlags.js';
|
|
30
|
+
import { resolveProjectIdentity } from './projectIdentity.js';
|
|
31
|
+
|
|
32
|
+
const JOURNAL_MAX = 256;
|
|
33
|
+
const OPS = new Set(['add', 'update', 'archive', 'forget', 'purge', 'restore']);
|
|
34
|
+
|
|
35
|
+
function sha1(text) {
|
|
36
|
+
return crypto.createHash('sha1').update(String(text)).digest('hex');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveTarget(target) {
|
|
40
|
+
if (target?.kind === 'project') {
|
|
41
|
+
const paths = buildRuntimePaths(target.projectRoot);
|
|
42
|
+
return { paths, recordsPath: paths.memoryV2RecordsPath };
|
|
43
|
+
}
|
|
44
|
+
if (target?.kind === 'user') {
|
|
45
|
+
const paths = buildUserPaths({ homeDir: target.homeDir });
|
|
46
|
+
return { paths, recordsPath: paths.memoryV2RecordsPath };
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`mutateMemory: unknown target kind ${target?.kind}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readJournal(journalPath) {
|
|
52
|
+
const doc = await readJsonIfExists(journalPath);
|
|
53
|
+
const entries = doc && typeof doc.entries === 'object' && doc.entries ? doc.entries : {};
|
|
54
|
+
return { entries };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function appendJournal(journalPath, key, entry) {
|
|
58
|
+
const journal = await readJournal(journalPath);
|
|
59
|
+
// FIFO cap: delete-then-set reorders the key to newest before trimming.
|
|
60
|
+
delete journal.entries[key];
|
|
61
|
+
journal.entries[key] = entry;
|
|
62
|
+
const keys = Object.keys(journal.entries);
|
|
63
|
+
while (keys.length > JOURNAL_MAX) {
|
|
64
|
+
delete journal.entries[keys.shift()];
|
|
65
|
+
}
|
|
66
|
+
await writeJson(journalPath, journal, { fsync: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function payloadFingerprint(payload) {
|
|
70
|
+
if (!payload || typeof payload !== 'object') return undefined;
|
|
71
|
+
return payload.source_fingerprint ?? payload.sourceFingerprint
|
|
72
|
+
?? (typeof payload.text === 'string' ? sha1(payload.text) : undefined);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildRecord(payload, idempotencyKey) {
|
|
76
|
+
const record = isRawRecord(payload) ? normalizeRecord(payload) : createRecord(payload);
|
|
77
|
+
if (!record) return null;
|
|
78
|
+
if (idempotencyKey) record.meta = { ...record.meta, idem: idempotencyKey };
|
|
79
|
+
return record;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function findRecord(records, payload) {
|
|
83
|
+
const id = payload?.id ?? payload?.recordId;
|
|
84
|
+
return id ? records.find((r) => r.id === id) : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* mutateMemory(target, envelope, {classifyAdd, guard} = {}) → Promise<result>
|
|
89
|
+
* target = {kind:'project', projectRoot} | {kind:'user', homeDir}
|
|
90
|
+
* envelope = {op, payload?, idempotencyKey?, expectedRevision?,
|
|
91
|
+
* expectedGeneration?, actor?, confirmScope?}
|
|
92
|
+
* result = {status:'ok'|'conflict'|'rejected'|'duplicate'|'not-found'|
|
|
93
|
+
* 'corrupt'|'disabled'|'review-queue-full', record?, reason?, labels?}
|
|
94
|
+
*/
|
|
95
|
+
export async function mutateMemory(target, envelope = {}, { classifyAdd, guard } = {}) {
|
|
96
|
+
const { recordsPath, paths } = resolveTarget(target);
|
|
97
|
+
const journalPath = path.join(paths.memoryV2Dir, 'mutation-journal.json');
|
|
98
|
+
const { op, payload, idempotencyKey, expectedRevision, expectedGeneration,
|
|
99
|
+
confirmScope } = envelope;
|
|
100
|
+
if (!OPS.has(op)) {
|
|
101
|
+
return { status: 'rejected', reason: `unknown-op:${String(op)}` };
|
|
102
|
+
}
|
|
103
|
+
let config = null;
|
|
104
|
+
let receiptStage = null;
|
|
105
|
+
let projectId = null;
|
|
106
|
+
if (target.kind === 'project') {
|
|
107
|
+
try {
|
|
108
|
+
config = await loadRuntimeConfig(target.projectRoot);
|
|
109
|
+
if (config?.memoryV2?.enabled === false) return { status: 'disabled' };
|
|
110
|
+
} catch {
|
|
111
|
+
// unreadable config → default enabled, keep going
|
|
112
|
+
}
|
|
113
|
+
// M06 rollout flag (SPEC §5 FR-003): writer stage 'off' (incl. killSwitch
|
|
114
|
+
// or an unlisted canary project) disables the write lane entirely —
|
|
115
|
+
// flags only reduce capability. 'shadow' runs the same write and emits
|
|
116
|
+
// a bounded content-free receipt; 'canary'/'default' are live.
|
|
117
|
+
const identity = await resolveProjectIdentity(target.projectRoot, {
|
|
118
|
+
homeDir: target.homeDir, mint: false,
|
|
119
|
+
}).catch(() => null);
|
|
120
|
+
projectId = identity?.projectId ?? null;
|
|
121
|
+
const writerStage = resolveMemoryStage(config, 'writer', { projectId });
|
|
122
|
+
if (writerStage === 'off') {
|
|
123
|
+
return { status: 'disabled', reason: 'rollout-stage' };
|
|
124
|
+
}
|
|
125
|
+
receiptStage = writerStage === 'shadow' ? 'shadow' : null;
|
|
126
|
+
}
|
|
127
|
+
const guardFn = guard ?? ((p) => guardWritePayload(p, { config, projectId }));
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const startedAt = receiptStage ? Date.now() : 0;
|
|
131
|
+
const result = await mutateRecordStore(recordsPath, async (records, prior) => {
|
|
132
|
+
// 2. Idempotency — journal hit or a record already stamped meta.idem.
|
|
133
|
+
if (idempotencyKey) {
|
|
134
|
+
const journal = await readJournal(journalPath);
|
|
135
|
+
const hit = journal.entries[idempotencyKey];
|
|
136
|
+
if (hit) {
|
|
137
|
+
return { result: { status: 'duplicate', record: records.find((r) => r.id === hit.recordId) } };
|
|
138
|
+
}
|
|
139
|
+
const stamped = records.find((r) => r.meta?.idem === idempotencyKey);
|
|
140
|
+
if (stamped) return { result: { status: 'duplicate', record: stamped } };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 3. Tombstone check — add/restore must never resurrect purged data.
|
|
144
|
+
if (op === 'add' && isTombstoned(prior, {
|
|
145
|
+
id: payload?.id, fingerprint: payloadFingerprint(payload),
|
|
146
|
+
})) {
|
|
147
|
+
return { result: { status: 'rejected', reason: 'tombstoned' } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 4. Optimistic checks.
|
|
151
|
+
if (expectedGeneration !== undefined && expectedGeneration !== prior.generation) {
|
|
152
|
+
return { result: { status: 'conflict', reason: 'generation-mismatch' } };
|
|
153
|
+
}
|
|
154
|
+
const existing = op === 'add' || op === 'restore' ? undefined : findRecord(records, payload);
|
|
155
|
+
if (op !== 'add' && op !== 'restore' && !existing) {
|
|
156
|
+
return { result: { status: 'not-found' } };
|
|
157
|
+
}
|
|
158
|
+
if (expectedRevision !== undefined) {
|
|
159
|
+
if (!existing || existing.revision !== expectedRevision) {
|
|
160
|
+
return { result: { status: 'conflict', reason: 'revision-mismatch' } };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const postWrite = idempotencyKey
|
|
165
|
+
? async () => appendJournal(journalPath, idempotencyKey, {
|
|
166
|
+
op, recordId: appliedRecordId, at: Date.now(),
|
|
167
|
+
})
|
|
168
|
+
: null;
|
|
169
|
+
let appliedRecordId;
|
|
170
|
+
|
|
171
|
+
// 5-7. Per-op apply.
|
|
172
|
+
if (op === 'add') {
|
|
173
|
+
const classify = classifyAdd ?? ((p, recs) => classifyWrite(p, recs));
|
|
174
|
+
const decision = await classify(payload, records);
|
|
175
|
+
if (decision?.action === 'NOOP') {
|
|
176
|
+
const target = decision.targetId
|
|
177
|
+
? records.find((r) => r.id === decision.targetId)
|
|
178
|
+
: findRecord(records, payload);
|
|
179
|
+
return { result: { status: 'duplicate', record: target } };
|
|
180
|
+
}
|
|
181
|
+
if (decision?.action === 'REVIEW') {
|
|
182
|
+
const pending = records.filter((r) => r.meta?.review_status === 'pending').length;
|
|
183
|
+
if (pending >= REVIEW_QUEUE_MAX) {
|
|
184
|
+
return { result: { status: 'review-queue-full' } };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const guarded = guardFn(payload);
|
|
188
|
+
if (guarded && guarded.ok === false) {
|
|
189
|
+
return { result: { status: 'rejected', reason: guarded.reason ?? 'secret-detected', labels: guarded.labels } };
|
|
190
|
+
}
|
|
191
|
+
// REVIEW records must persist even when the candidate itself is
|
|
192
|
+
// malformed (e.g. self-supersede) — strip the offending field so the
|
|
193
|
+
// quarantined record validates instead of crashing the write.
|
|
194
|
+
const recordPayload = decision?.action === 'REVIEW'
|
|
195
|
+
? { ...payload, supersedes: [] }
|
|
196
|
+
: payload;
|
|
197
|
+
let record;
|
|
198
|
+
try {
|
|
199
|
+
record = buildRecord(recordPayload, idempotencyKey);
|
|
200
|
+
} catch {
|
|
201
|
+
record = null;
|
|
202
|
+
}
|
|
203
|
+
if (!record) return { result: { status: 'rejected', reason: 'invalid-record' } };
|
|
204
|
+
if (decision?.action === 'REVIEW') {
|
|
205
|
+
record.status = 'archived';
|
|
206
|
+
record.meta = { ...record.meta, review_status: 'pending', review_reason: decision.reason };
|
|
207
|
+
appliedRecordId = record.id;
|
|
208
|
+
return { result: { status: 'ok', record, reason: 'review' }, records: [...records, record], postWrite };
|
|
209
|
+
}
|
|
210
|
+
if (decision?.action === 'ENRICH' && decision.targetId) {
|
|
211
|
+
const targetRec = records.find((r) => r.id === decision.targetId);
|
|
212
|
+
if (targetRec) {
|
|
213
|
+
const patched = applyRecordPatch(records, targetRec.id, {
|
|
214
|
+
evidence: mergeEvidence(targetRec.evidence, record.evidence),
|
|
215
|
+
revision: (targetRec.revision ?? 1) + 1,
|
|
216
|
+
});
|
|
217
|
+
appliedRecordId = patched.record.id;
|
|
218
|
+
return {
|
|
219
|
+
result: { status: 'ok', record: patched.record, reason: 'enriched' },
|
|
220
|
+
records: patched.records,
|
|
221
|
+
postWrite,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (decision?.action === 'SUPERSEDE') {
|
|
226
|
+
const targetIds = decision.targetIds
|
|
227
|
+
?? (decision.targetId ? [decision.targetId] : []);
|
|
228
|
+
let next = records;
|
|
229
|
+
for (const id of targetIds) {
|
|
230
|
+
const patched = applyRecordPatch(next, id, {
|
|
231
|
+
status: 'archived', superseded_by: record.id, reason: 'superseded',
|
|
232
|
+
});
|
|
233
|
+
if (patched) next = patched.records;
|
|
234
|
+
}
|
|
235
|
+
appliedRecordId = record.id;
|
|
236
|
+
return { result: { status: 'ok', record }, records: [...next, record], postWrite };
|
|
237
|
+
}
|
|
238
|
+
appliedRecordId = record.id;
|
|
239
|
+
return { result: { status: 'ok', record }, records: [...records, record], postWrite };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (op === 'update' || op === 'archive' || op === 'forget') {
|
|
243
|
+
const patch = op === 'update'
|
|
244
|
+
? { ...(payload?.patch ?? payload) }
|
|
245
|
+
: { status: 'archived' };
|
|
246
|
+
delete patch.id;
|
|
247
|
+
const guarded = op === 'update' ? guardFn(patch) : { ok: true };
|
|
248
|
+
if (guarded && guarded.ok === false) {
|
|
249
|
+
return { result: { status: 'rejected', reason: guarded.reason ?? 'secret-detected', labels: guarded.labels } };
|
|
250
|
+
}
|
|
251
|
+
const patched = applyRecordPatch(records, existing.id, {
|
|
252
|
+
...patch, revision: (existing.revision ?? 1) + 1,
|
|
253
|
+
});
|
|
254
|
+
appliedRecordId = patched.record.id;
|
|
255
|
+
return { result: { status: 'ok', record: patched.record }, records: patched.records, postWrite };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (op === 'purge') {
|
|
259
|
+
if (target.kind === 'user' && confirmScope !== 'user') {
|
|
260
|
+
return { result: { status: 'rejected', reason: 'scope-confirm-required' } };
|
|
261
|
+
}
|
|
262
|
+
const tombstone = {
|
|
263
|
+
id: existing.id,
|
|
264
|
+
fingerprint: existing.source_fingerprint ?? sha1(existing.text),
|
|
265
|
+
purged_at: Date.now(),
|
|
266
|
+
};
|
|
267
|
+
appliedRecordId = existing.id;
|
|
268
|
+
return {
|
|
269
|
+
result: { status: 'ok', record: existing },
|
|
270
|
+
records: records.filter((r) => r.id !== existing.id),
|
|
271
|
+
tombstones: [...(prior.tombstones ?? []), tombstone],
|
|
272
|
+
postWrite,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// op === 'restore' — explicit migrate/restore path: fingerprint dedupe,
|
|
277
|
+
// tombstone precedence, guard in redact-mode.
|
|
278
|
+
const incoming = Array.isArray(payload?.records) ? payload.records : [];
|
|
279
|
+
const fingerprints = new Set(records.map((r) => r.source_fingerprint).filter(Boolean));
|
|
280
|
+
const ids = new Set(records.map((r) => r.id));
|
|
281
|
+
const additions = [];
|
|
282
|
+
for (const raw of incoming) {
|
|
283
|
+
const redacted = redactWritePayload(raw, { config }).payload;
|
|
284
|
+
const record = normalizeRecord(redacted);
|
|
285
|
+
if (!record) continue;
|
|
286
|
+
const fp = record.source_fingerprint ?? sha1(record.text);
|
|
287
|
+
if (ids.has(record.id) || fingerprints.has(fp)) continue;
|
|
288
|
+
if (isTombstoned(prior, { id: record.id, fingerprint: fp })) continue;
|
|
289
|
+
ids.add(record.id);
|
|
290
|
+
fingerprints.add(fp);
|
|
291
|
+
additions.push(record);
|
|
292
|
+
}
|
|
293
|
+
appliedRecordId = additions[0]?.id;
|
|
294
|
+
// No-op restore: nothing to add → skip the write entirely so a
|
|
295
|
+
// re-run leaves the doc byte-identical (no generation bump).
|
|
296
|
+
if (additions.length === 0) {
|
|
297
|
+
return { result: { status: 'ok', added: 0, records: [] } };
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
result: { status: 'ok', added: additions.length, records: additions },
|
|
301
|
+
records: [...records, ...additions],
|
|
302
|
+
postWrite,
|
|
303
|
+
};
|
|
304
|
+
}, { onCorrupt: op === 'restore' ? 'quarantine-empty' : undefined });
|
|
305
|
+
if (receiptStage) {
|
|
306
|
+
// Shadow receipt (FR-004): outcome/code/latency band only — never the
|
|
307
|
+
// record body. Swallowed internally; cannot break the write.
|
|
308
|
+
await appendRolloutReceipt(target.projectRoot, {
|
|
309
|
+
plane: 'writer',
|
|
310
|
+
stage: receiptStage,
|
|
311
|
+
outcome: result.status === 'ok' ? 'ok' : result.status === 'rejected' ? 'deny' : 'error',
|
|
312
|
+
code: result.status,
|
|
313
|
+
latencyBand: latencyBandForMs(Date.now() - startedAt),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return result;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error instanceof StoreCorruptError || error?.name === 'StoreCorruptError') {
|
|
319
|
+
return { status: 'corrupt', reason: error.code };
|
|
320
|
+
}
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Memory eligibility policy — single source of truth (M01-02).
|
|
2
|
+
// Pure module: no I/O, no imports. Deny-by-default on every unbound or
|
|
3
|
+
// ambiguous path; the only escape hatch is an explicit policy override.
|
|
4
|
+
|
|
5
|
+
export const POLICY_VERSION = 'w1-2';
|
|
6
|
+
|
|
7
|
+
const REPO_BOUND_SCOPES = Object.freeze(['repo', 'project', 'task', 'session']);
|
|
8
|
+
const UNVERIFIED_PROVENANCE = Object.freeze([
|
|
9
|
+
'learning-observation',
|
|
10
|
+
'learning-candidate',
|
|
11
|
+
'delta-overlay',
|
|
12
|
+
]);
|
|
13
|
+
// Trust tiers that must never surface as a current authoritative fact
|
|
14
|
+
// (TASK-005 / FR-010): candidates and observations are unreviewed input —
|
|
15
|
+
// returning them as eligible is a false-authority breach. 'verified' and
|
|
16
|
+
// 'legacy-unverified' (migrated records) remain admissible.
|
|
17
|
+
const NON_AUTHORITY_TRUST_TIERS = Object.freeze(['candidate', 'observation']);
|
|
18
|
+
const REQUESTED_SCOPES = Object.freeze(['repo', 'user', 'session', 'task', 'all']);
|
|
19
|
+
|
|
20
|
+
function deny(reason) {
|
|
21
|
+
return { ok: false, reason };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// hostContext = {projectId: string|null, ambiguous?: boolean, sessionId?, taskId?, branch?}
|
|
25
|
+
// requestedScope ∈ 'repo'|'user'|'session'|'task'|'all' (default 'all').
|
|
26
|
+
// → {scope, projectId, includeUser, denied}
|
|
27
|
+
export function resolveEffectiveScope(hostContext = {}, requestedScope = 'all') {
|
|
28
|
+
const scope = REQUESTED_SCOPES.includes(requestedScope) ? requestedScope : 'all';
|
|
29
|
+
const projectId = typeof hostContext.projectId === 'string' ? hostContext.projectId : null;
|
|
30
|
+
const ambiguous = hostContext.ambiguous === true;
|
|
31
|
+
|
|
32
|
+
if (scope === 'user') {
|
|
33
|
+
return { scope, projectId: null, includeUser: true, denied: false };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (scope === 'all') {
|
|
37
|
+
// 'all' keeps projectId but does not opt into user records
|
|
38
|
+
// (user-repo-precedence: user records need explicit opt-in).
|
|
39
|
+
return { scope, projectId, includeUser: false, denied: false };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// repo / task / session require a non-null, non-ambiguous project binding.
|
|
43
|
+
if (projectId == null || ambiguous) {
|
|
44
|
+
return { scope, projectId, includeUser: false, denied: true };
|
|
45
|
+
}
|
|
46
|
+
return { scope, projectId, includeUser: false, denied: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// record per src/core/memory/records.js
|
|
50
|
+
// (scope, status, project_id, valid_until, provenance, type).
|
|
51
|
+
// context = {projectId, projectAliases?: string[], includeUser, now?}
|
|
52
|
+
// policy = {legacyNullProjectId: 'deny'|'compat'} — default 'deny'.
|
|
53
|
+
// → {ok: boolean, reason: string}
|
|
54
|
+
export function eligible(record, context = {}, policy = {}) {
|
|
55
|
+
const now = typeof context.now === 'number' ? context.now : Date.now();
|
|
56
|
+
const legacyNull = policy.legacyNullProjectId === 'compat' ? 'compat' : 'deny';
|
|
57
|
+
|
|
58
|
+
if (record == null || typeof record !== 'object') {
|
|
59
|
+
return deny('status');
|
|
60
|
+
}
|
|
61
|
+
if (record.status !== 'active') {
|
|
62
|
+
return deny('status');
|
|
63
|
+
}
|
|
64
|
+
if (record.valid_until != null && record.valid_until <= now) {
|
|
65
|
+
return deny('expired');
|
|
66
|
+
}
|
|
67
|
+
if (UNVERIFIED_PROVENANCE.includes(record.provenance)) {
|
|
68
|
+
return deny('unverified-provenance');
|
|
69
|
+
}
|
|
70
|
+
if (NON_AUTHORITY_TRUST_TIERS.includes(record.trust_tier)) {
|
|
71
|
+
return deny('non-authority-trust-tier');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (record.scope === 'user') {
|
|
75
|
+
if (context.includeUser !== true) {
|
|
76
|
+
return deny('user-scope-not-included');
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, reason: 'ok' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (REPO_BOUND_SCOPES.includes(record.scope)) {
|
|
82
|
+
if (context.projectId == null) {
|
|
83
|
+
return deny('no-project-binding');
|
|
84
|
+
}
|
|
85
|
+
if (record.project_id != null && record.project_id !== context.projectId
|
|
86
|
+
&& !(Array.isArray(context.projectAliases)
|
|
87
|
+
&& context.projectAliases.includes(record.project_id))) {
|
|
88
|
+
return deny('project-mismatch');
|
|
89
|
+
}
|
|
90
|
+
if (record.project_id == null && legacyNull !== 'compat') {
|
|
91
|
+
return deny('null-project-id');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { ok: true, reason: 'ok' };
|
|
96
|
+
}
|