@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,286 @@
|
|
|
1
|
+
// Memory eligibility policy — shipped mirror of src/core/memory/policy.js
|
|
2
|
+
// (M01-04). Self-contained: no src/ imports, no UKit runtime deps, so
|
|
3
|
+
// route-task/reinject hooks enforce the identical policy version.
|
|
4
|
+
// READ-ONLY by contract: resolveProjectIdentityMirror NEVER mints and NEVER
|
|
5
|
+
// writes the registry — minting stays host-side in src/core/memory/
|
|
6
|
+
// projectIdentity.js.
|
|
7
|
+
//
|
|
8
|
+
// Parity is enforced by tests/consistency/memoryPolicyParity.test.js — keep
|
|
9
|
+
// POLICY_VERSION and every verdict identical to src/core/memory/policy.js.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
export const POLICY_VERSION = 'w1-2';
|
|
16
|
+
|
|
17
|
+
const REPO_BOUND_SCOPES = Object.freeze(['repo', 'project', 'task', 'session']);
|
|
18
|
+
const UNVERIFIED_PROVENANCE = Object.freeze([
|
|
19
|
+
'learning-observation',
|
|
20
|
+
'learning-candidate',
|
|
21
|
+
'delta-overlay',
|
|
22
|
+
]);
|
|
23
|
+
// Trust tiers that must never surface as a current authoritative fact
|
|
24
|
+
// (TASK-005 / FR-010): candidates and observations are unreviewed input —
|
|
25
|
+
// returning them as eligible is a false-authority breach. 'verified' and
|
|
26
|
+
// 'legacy-unverified' (migrated records) remain admissible.
|
|
27
|
+
const NON_AUTHORITY_TRUST_TIERS = Object.freeze(['candidate', 'observation']);
|
|
28
|
+
const REQUESTED_SCOPES = Object.freeze(['repo', 'user', 'session', 'task', 'all']);
|
|
29
|
+
|
|
30
|
+
function deny(reason) {
|
|
31
|
+
return { ok: false, reason };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// hostContext = {projectId: string|null, ambiguous?: boolean, sessionId?, taskId?, branch?}
|
|
35
|
+
// requestedScope ∈ 'repo'|'user'|'session'|'task'|'all' (default 'all').
|
|
36
|
+
// → {scope, projectId, includeUser, denied}
|
|
37
|
+
export function resolveEffectiveScope(hostContext = {}, requestedScope = 'all') {
|
|
38
|
+
const scope = REQUESTED_SCOPES.includes(requestedScope) ? requestedScope : 'all';
|
|
39
|
+
const projectId = typeof hostContext.projectId === 'string' ? hostContext.projectId : null;
|
|
40
|
+
const ambiguous = hostContext.ambiguous === true;
|
|
41
|
+
|
|
42
|
+
if (scope === 'user') {
|
|
43
|
+
return { scope, projectId: null, includeUser: true, denied: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (scope === 'all') {
|
|
47
|
+
// 'all' keeps projectId but does not opt into user records
|
|
48
|
+
// (user-repo-precedence: user records need explicit opt-in).
|
|
49
|
+
return { scope, projectId, includeUser: false, denied: false };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// repo / task / session require a non-null, non-ambiguous project binding.
|
|
53
|
+
if (projectId == null || ambiguous) {
|
|
54
|
+
return { scope, projectId, includeUser: false, denied: true };
|
|
55
|
+
}
|
|
56
|
+
return { scope, projectId, includeUser: false, denied: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// record per src/core/memory/records.js
|
|
60
|
+
// (scope, status, project_id, valid_until, provenance, type).
|
|
61
|
+
// context = {projectId, projectAliases?: string[], includeUser, now?}
|
|
62
|
+
// policy = {legacyNullProjectId: 'deny'|'compat'} — default 'deny'.
|
|
63
|
+
// → {ok: boolean, reason: string}
|
|
64
|
+
export function eligible(record, context = {}, policy = {}) {
|
|
65
|
+
const now = typeof context.now === 'number' ? context.now : Date.now();
|
|
66
|
+
const legacyNull = policy.legacyNullProjectId === 'compat' ? 'compat' : 'deny';
|
|
67
|
+
|
|
68
|
+
if (record == null || typeof record !== 'object') {
|
|
69
|
+
return deny('status');
|
|
70
|
+
}
|
|
71
|
+
if (record.status !== 'active') {
|
|
72
|
+
return deny('status');
|
|
73
|
+
}
|
|
74
|
+
if (record.valid_until != null && record.valid_until <= now) {
|
|
75
|
+
return deny('expired');
|
|
76
|
+
}
|
|
77
|
+
if (UNVERIFIED_PROVENANCE.includes(record.provenance)) {
|
|
78
|
+
return deny('unverified-provenance');
|
|
79
|
+
}
|
|
80
|
+
if (NON_AUTHORITY_TRUST_TIERS.includes(record.trust_tier)) {
|
|
81
|
+
return deny('non-authority-trust-tier');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (record.scope === 'user') {
|
|
85
|
+
if (context.includeUser !== true) {
|
|
86
|
+
return deny('user-scope-not-included');
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, reason: 'ok' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (REPO_BOUND_SCOPES.includes(record.scope)) {
|
|
92
|
+
if (context.projectId == null) {
|
|
93
|
+
return deny('no-project-binding');
|
|
94
|
+
}
|
|
95
|
+
if (record.project_id != null && record.project_id !== context.projectId
|
|
96
|
+
&& !(Array.isArray(context.projectAliases)
|
|
97
|
+
&& context.projectAliases.includes(record.project_id))) {
|
|
98
|
+
return deny('project-mismatch');
|
|
99
|
+
}
|
|
100
|
+
if (record.project_id == null && legacyNull !== 'compat') {
|
|
101
|
+
return deny('null-project-id');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { ok: true, reason: 'ok' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- Read-only project identity mirror (SPEC §5 FR-001..003) ----
|
|
109
|
+
// Same resolution order as src/core/memory/projectIdentity.js minus minting:
|
|
110
|
+
// (1) canonical realpath in registry roots; (2) git common-dir binding;
|
|
111
|
+
// (3) alias index — multiple hits → ambiguous deny; otherwise unverified.
|
|
112
|
+
|
|
113
|
+
function registryPathFor(homeDir) {
|
|
114
|
+
const home = typeof homeDir === 'string' && homeDir ? homeDir : os.homedir();
|
|
115
|
+
return path.join(home, '.ukit', 'storage', 'projects', 'registry.json');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readJsonIfExists(filePath) {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error?.code === 'ENOENT') return null;
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Tolerant registry read. Missing → empty registry. Corrupt → null sentinel
|
|
128
|
+
// (caller degrades to `unverified`; the file is NEVER overwritten on read).
|
|
129
|
+
async function readRegistry(regPath) {
|
|
130
|
+
let data;
|
|
131
|
+
try {
|
|
132
|
+
data = await readJsonIfExists(regPath);
|
|
133
|
+
} catch {
|
|
134
|
+
return null; // corrupt JSON — degrade, never throw, never rewrite
|
|
135
|
+
}
|
|
136
|
+
if (data === null) return { schemaVersion: 1, projects: {}, aliasIndex: {} };
|
|
137
|
+
if (typeof data !== 'object' || typeof data.projects !== 'object' || data.projects === null) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (typeof data.aliasIndex !== 'object' || data.aliasIndex === null) {
|
|
141
|
+
data.aliasIndex = {};
|
|
142
|
+
}
|
|
143
|
+
return data;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function realpathOrNull(target) {
|
|
147
|
+
try {
|
|
148
|
+
return await fs.realpath(target);
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// `.git` dir → itself; `.git` file (`gitdir: <p>`) → `<p>/commondir` (linked
|
|
155
|
+
// worktrees share the main common dir). Null when there is no git binding.
|
|
156
|
+
async function resolveGitCommonDir(projectRoot) {
|
|
157
|
+
const gitPath = path.join(projectRoot, '.git');
|
|
158
|
+
let stat;
|
|
159
|
+
try {
|
|
160
|
+
stat = await fs.stat(gitPath);
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (stat.isDirectory()) {
|
|
166
|
+
return await realpathOrNull(gitPath);
|
|
167
|
+
}
|
|
168
|
+
if (!stat.isFile()) return null;
|
|
169
|
+
|
|
170
|
+
let content;
|
|
171
|
+
try {
|
|
172
|
+
content = await fs.readFile(gitPath, 'utf8');
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const match = content.match(/^gitdir:\s*(.+)$/m);
|
|
177
|
+
if (!match) return null;
|
|
178
|
+
const gitDir = path.resolve(projectRoot, match[1].trim());
|
|
179
|
+
|
|
180
|
+
let commonDirRel;
|
|
181
|
+
try {
|
|
182
|
+
commonDirRel = (await fs.readFile(path.join(gitDir, 'commondir'), 'utf8')).trim();
|
|
183
|
+
} catch {
|
|
184
|
+
commonDirRel = '';
|
|
185
|
+
}
|
|
186
|
+
const commonDir = commonDirRel ? path.resolve(gitDir, commonDirRel) : gitDir;
|
|
187
|
+
return await realpathOrNull(commonDir);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function aliasesFor(projectRoot, realRoot) {
|
|
191
|
+
const names = new Set();
|
|
192
|
+
const base = path.basename(realRoot ?? projectRoot);
|
|
193
|
+
if (base) names.add(base);
|
|
194
|
+
const pkg = await readJsonIfExists(path.join(projectRoot, 'package.json')).catch(() => null);
|
|
195
|
+
if (typeof pkg?.name === 'string' && pkg.name.trim() !== '') {
|
|
196
|
+
names.add(pkg.name.trim());
|
|
197
|
+
}
|
|
198
|
+
return [...names];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function findByRoot(registry, realRoot) {
|
|
202
|
+
for (const [projectId, entry] of Object.entries(registry.projects)) {
|
|
203
|
+
if (Array.isArray(entry?.roots) && entry.roots.includes(realRoot)) {
|
|
204
|
+
return projectId;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function findByGitCommonDir(registry, commonDir) {
|
|
211
|
+
if (!commonDir) return null;
|
|
212
|
+
for (const [projectId, entry] of Object.entries(registry.projects)) {
|
|
213
|
+
if (Array.isArray(entry?.gitCommonDirs) && entry.gitCommonDirs.includes(commonDir)) {
|
|
214
|
+
return projectId;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function findByAlias(registry, aliases) {
|
|
221
|
+
const hits = new Set();
|
|
222
|
+
for (const name of aliases) {
|
|
223
|
+
const entry = registry.aliasIndex?.[name];
|
|
224
|
+
if (entry === 'ambiguous') {
|
|
225
|
+
hits.add('ambiguous');
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (Array.isArray(entry)) {
|
|
229
|
+
for (const id of entry) hits.add(id);
|
|
230
|
+
} else if (typeof entry === 'string' && entry) {
|
|
231
|
+
hits.add(entry);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return hits;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Read-only mirror of resolveProjectIdentity (mint:false path only).
|
|
239
|
+
* Corrupt/missing registry → {projectId:null, source:'unverified'}.
|
|
240
|
+
* NEVER mints, NEVER writes.
|
|
241
|
+
*
|
|
242
|
+
* @returns {Promise<{projectId: string|null, source: 'registry'|'git-common-dir'|'alias'|'unverified', ambiguous: boolean}>}
|
|
243
|
+
*/
|
|
244
|
+
export async function resolveProjectIdentityMirror(projectRoot, { homeDir } = {}) {
|
|
245
|
+
const unverified = { projectId: null, source: 'unverified', ambiguous: false };
|
|
246
|
+
const regPath = registryPathFor(homeDir);
|
|
247
|
+
|
|
248
|
+
const registry = await readRegistry(regPath);
|
|
249
|
+
if (registry === null) return unverified; // corrupt — never overwrite on read
|
|
250
|
+
|
|
251
|
+
const realRoot = (await realpathOrNull(projectRoot)) ?? path.resolve(projectRoot);
|
|
252
|
+
const byRoot = findByRoot(registry, realRoot);
|
|
253
|
+
if (byRoot) {
|
|
254
|
+
return {
|
|
255
|
+
projectId: byRoot,
|
|
256
|
+
source: 'registry',
|
|
257
|
+
ambiguous: false,
|
|
258
|
+
aliases: Array.isArray(registry.projects[byRoot]?.aliases)
|
|
259
|
+
? registry.projects[byRoot].aliases.filter((a) => typeof a === 'string')
|
|
260
|
+
: [],
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const commonDir = await resolveGitCommonDir(projectRoot);
|
|
265
|
+
const byGit = findByGitCommonDir(registry, commonDir);
|
|
266
|
+
if (byGit) {
|
|
267
|
+
return {
|
|
268
|
+
projectId: byGit,
|
|
269
|
+
source: 'git-common-dir',
|
|
270
|
+
ambiguous: false,
|
|
271
|
+
aliases: Array.isArray(registry.projects[byGit]?.aliases)
|
|
272
|
+
? registry.projects[byGit].aliases.filter((a) => typeof a === 'string')
|
|
273
|
+
: [],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const aliases = await aliasesFor(projectRoot, realRoot);
|
|
278
|
+
// Alias is a weak display-name signal only: any hit for a different root
|
|
279
|
+
// denies (ambiguous) rather than letting a same-named repo inherit an id.
|
|
280
|
+
const aliasHits = findByAlias(registry, aliases);
|
|
281
|
+
if (aliasHits.size > 0) {
|
|
282
|
+
return { projectId: null, source: 'alias', ambiguous: true };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return unverified;
|
|
286
|
+
}
|
|
@@ -372,6 +372,9 @@ function buildRawOutputText({ command = '', stdout = '', stderr = '', exitCode =
|
|
|
372
372
|
}
|
|
373
373
|
|
|
374
374
|
function buildRecoveryHintSummary(summary, rawPath, { tokensBefore = 0 } = {}) {
|
|
375
|
+
// TASK-003 fix round 1: a failed tee persist returns rawPath='' — never emit a
|
|
376
|
+
// dangling `- Full output: ` hint that points at nothing.
|
|
377
|
+
if (!rawPath) return null;
|
|
375
378
|
const recoveryLine = `- Full output: ${rawPath}`;
|
|
376
379
|
const summaryLines = String(summary ?? '')
|
|
377
380
|
.split(/\r?\n/)
|
|
@@ -24,6 +24,12 @@ import {
|
|
|
24
24
|
readResumableRun,
|
|
25
25
|
resumableRunSourceFingerprint,
|
|
26
26
|
} from './resumable-run.mjs';
|
|
27
|
+
import {
|
|
28
|
+
eligible as memoryRecordEligible,
|
|
29
|
+
resolveProjectIdentityMirror,
|
|
30
|
+
} from './memory-policy.mjs';
|
|
31
|
+
import { resolveRecordFreshness } from './memory-freshness.mjs';
|
|
32
|
+
import { resolveMemoryStage } from './memory-flags.mjs';
|
|
27
33
|
|
|
28
34
|
// Hook-context self-deadline (2.4.1 orphan-leak class): the reinject-context hook passes
|
|
29
35
|
// UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
|
|
@@ -320,6 +326,13 @@ function defaultRuntimeConfig() {
|
|
|
320
326
|
progressiveRetrieval: true,
|
|
321
327
|
maxInjectionTokens: 320,
|
|
322
328
|
},
|
|
329
|
+
// M06 rollout defaults (FR-001): the shipped default keeps the v2 read
|
|
330
|
+
// lane live; absent user config must not resolve 'off'.
|
|
331
|
+
memoryV2: {
|
|
332
|
+
eligibility: { stage: 'default' },
|
|
333
|
+
killSwitch: false,
|
|
334
|
+
canaryProjects: [],
|
|
335
|
+
},
|
|
323
336
|
};
|
|
324
337
|
}
|
|
325
338
|
|
|
@@ -913,6 +926,13 @@ function getMemoryTimestamp(item) {
|
|
|
913
926
|
|
|
914
927
|
function buildMemorySegments(item) {
|
|
915
928
|
const content = item.content ?? {};
|
|
929
|
+
if (item.type === 'record') {
|
|
930
|
+
return [
|
|
931
|
+
{ text: content.recordType, weight: 1 },
|
|
932
|
+
{ text: content.text, weight: 3 },
|
|
933
|
+
];
|
|
934
|
+
}
|
|
935
|
+
|
|
916
936
|
if (item.type === 'project') {
|
|
917
937
|
return [
|
|
918
938
|
{ text: content.name, weight: 2 },
|
|
@@ -991,21 +1011,95 @@ async function readDirectoryJsonItems(dirPath) {
|
|
|
991
1011
|
return items.sort((left, right) => left.fileName.localeCompare(right.fileName));
|
|
992
1012
|
}
|
|
993
1013
|
|
|
994
|
-
|
|
1014
|
+
// M01-05: project identity is owned by memory-policy.mjs (read-only mirror —
|
|
1015
|
+
// never mints, never writes). Legacy project/session items are keyed by the
|
|
1016
|
+
// display name, so binding also accepts the alias names (pkg name, basename).
|
|
1017
|
+
async function resolveProjectBinding(projectRoot) {
|
|
1018
|
+
const identity = await resolveProjectIdentityMirror(projectRoot).catch(() => null);
|
|
1019
|
+
const names = new Set();
|
|
1020
|
+
const base = path.basename(projectRoot);
|
|
1021
|
+
if (base) names.add(base);
|
|
995
1022
|
const pkg = await readJson(path.join(projectRoot, 'package.json'), null);
|
|
996
1023
|
if (typeof pkg?.name === 'string' && pkg.name.trim()) {
|
|
997
|
-
|
|
1024
|
+
names.add(pkg.name.trim());
|
|
1025
|
+
}
|
|
1026
|
+
return {
|
|
1027
|
+
projectId: identity?.projectId ?? null,
|
|
1028
|
+
ambiguous: identity?.ambiguous === true,
|
|
1029
|
+
// Registry-attested aliases only (never raw pkg name) — migrated v1
|
|
1030
|
+
// records carry the legacy name as project_id.
|
|
1031
|
+
aliases: Array.isArray(identity?.aliases) ? identity.aliases : [],
|
|
1032
|
+
names,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function matchesProjectBinding(item, binding) {
|
|
1037
|
+
const boundId = item.type === 'project' ? item.content?.id : item.content?.projectId;
|
|
1038
|
+
if (boundId == null) return false;
|
|
1039
|
+
if (binding.projectId != null && boundId === binding.projectId) return true;
|
|
1040
|
+
return binding.names.has(boundId);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// v2/legacy dedup (M01-05): meta.legacyId → session:<id>; project_id →
|
|
1044
|
+
// project:<id>. The legacy item wins — richer structured summary.
|
|
1045
|
+
function recordLegacyKey(item) {
|
|
1046
|
+
const record = item.record;
|
|
1047
|
+
if (!record) return null;
|
|
1048
|
+
const legacyId = record.meta?.legacyId;
|
|
1049
|
+
if (typeof legacyId === 'string' && legacyId) {
|
|
1050
|
+
return `session:${legacyId}`;
|
|
998
1051
|
}
|
|
1052
|
+
if (typeof record.project_id === 'string' && record.project_id) {
|
|
1053
|
+
return `project:${record.project_id}`;
|
|
1054
|
+
}
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
999
1057
|
|
|
1000
|
-
|
|
1058
|
+
const MEMORY_V2_RECORD_POOL_LIMIT = 20;
|
|
1059
|
+
|
|
1060
|
+
// v2 lane (M01-05): approved records live in a single records.json document.
|
|
1061
|
+
// Missing/malformed store → [] (never-throw, same as readDirectoryJsonItems).
|
|
1062
|
+
// Stale snapshots excluded here AND re-checked by eligible() downstream.
|
|
1063
|
+
// M06 rollout flag (FR-005): eligibility stage 'off' (incl. killSwitch or an
|
|
1064
|
+
// unlisted canary project) suppresses v2 record items entirely — identical
|
|
1065
|
+
// verdict to src/core/memory/memoryFlags.js via memory-flags.mjs.
|
|
1066
|
+
// Accepts either a runtimePaths object or a projectRoot string (tests).
|
|
1067
|
+
export async function listMemoryV2RecordItems(runtimePathsOrRoot, { config, projectId } = {}) {
|
|
1068
|
+
const memoryRoot = typeof runtimePathsOrRoot === 'string'
|
|
1069
|
+
? path.join(runtimePathsOrRoot, '.ukit', 'storage', 'memory')
|
|
1070
|
+
: runtimePathsOrRoot.memoryRoot;
|
|
1071
|
+
if (resolveMemoryStage(config, 'eligibility', { projectId }) === 'off') {
|
|
1072
|
+
return [];
|
|
1073
|
+
}
|
|
1074
|
+
const doc = await readJson(path.join(memoryRoot, 'v2', 'records.json'), null);
|
|
1075
|
+
const records = Array.isArray(doc?.records) ? doc.records : [];
|
|
1076
|
+
const now = Date.now();
|
|
1077
|
+
|
|
1078
|
+
return records
|
|
1079
|
+
.filter((record) => record && typeof record === 'object')
|
|
1080
|
+
.filter((record) => record.status === 'active')
|
|
1081
|
+
.filter((record) => record.valid_until == null || record.valid_until > now)
|
|
1082
|
+
.sort((left, right) => (right.created_at ?? 0) - (left.created_at ?? 0))
|
|
1083
|
+
.slice(0, MEMORY_V2_RECORD_POOL_LIMIT)
|
|
1084
|
+
.map((record) => ({
|
|
1085
|
+
id: `record:${record.id}`,
|
|
1086
|
+
type: 'record',
|
|
1087
|
+
record,
|
|
1088
|
+
content: {
|
|
1089
|
+
recordType: record.type,
|
|
1090
|
+
text: record.text,
|
|
1091
|
+
projectId: record.project_id,
|
|
1092
|
+
updatedAt: record.created_at,
|
|
1093
|
+
},
|
|
1094
|
+
}));
|
|
1001
1095
|
}
|
|
1002
1096
|
|
|
1003
|
-
async function listMemoryItems(projectRoot) {
|
|
1097
|
+
async function listMemoryItems(projectRoot, { config, projectId } = {}) {
|
|
1004
1098
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1005
1099
|
const userMemory = (await readJson(runtimePaths.userMemoryPath, null)) ?? { preferences: {}, rules: [] };
|
|
1006
1100
|
const projectMemories = await readDirectoryJsonItems(runtimePaths.projectsDir);
|
|
1007
1101
|
const sessionMemories = await readDirectoryJsonItems(runtimePaths.sessionsDir);
|
|
1008
|
-
|
|
1102
|
+
const recordMemories = await listMemoryV2RecordItems(runtimePaths, { config, projectId });
|
|
1009
1103
|
return [
|
|
1010
1104
|
{
|
|
1011
1105
|
id: 'user:user',
|
|
@@ -1022,11 +1116,27 @@ async function listMemoryItems(projectRoot) {
|
|
|
1022
1116
|
type: 'session',
|
|
1023
1117
|
content: item.content,
|
|
1024
1118
|
})),
|
|
1119
|
+
...recordMemories,
|
|
1025
1120
|
];
|
|
1026
1121
|
}
|
|
1027
1122
|
|
|
1028
|
-
|
|
1123
|
+
// M05-01 (FR-008): record snippets carry a freshness label — ' [stale]' or
|
|
1124
|
+
// ' [unknown]' — resolved via memory-freshness.mjs for the ≤2 rendered items
|
|
1125
|
+
// only. `unknown` is rendered only when the record carries evidence that
|
|
1126
|
+
// failed verification; no-evidence records stay unlabeled (token cost).
|
|
1127
|
+
function buildPreviousContextSnippet(item, freshness = null) {
|
|
1029
1128
|
const content = item.content ?? {};
|
|
1129
|
+
if (item.type === 'record') {
|
|
1130
|
+
const text = String(content.text ?? '').trim();
|
|
1131
|
+
const truncated = text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
1132
|
+
const label = freshness?.state === 'stale'
|
|
1133
|
+
? ' [stale]'
|
|
1134
|
+
: freshness?.state === 'unknown' && Array.isArray(item.record?.evidence) && item.record.evidence.length > 0
|
|
1135
|
+
? ' [unknown]'
|
|
1136
|
+
: '';
|
|
1137
|
+
return `[${content.recordType ?? 'record'}] ${truncated}${label}`;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1030
1140
|
if (item.type === 'project') {
|
|
1031
1141
|
const decisions = compactPhraseList((content.decisions ?? []).map((decision) => decision.what), { limit: 1 });
|
|
1032
1142
|
const rules = compactPhraseList(content.activeRules ?? [], { limit: 1 });
|
|
@@ -1089,16 +1199,28 @@ async function buildPreviousContextLines(projectRoot, state, config) {
|
|
|
1089
1199
|
return [`- Previous context: ${sharedPreviousContextLine}`];
|
|
1090
1200
|
}
|
|
1091
1201
|
|
|
1092
|
-
const
|
|
1093
|
-
const items = await listMemoryItems(projectRoot);
|
|
1202
|
+
const binding = await resolveProjectBinding(projectRoot);
|
|
1203
|
+
const items = await listMemoryItems(projectRoot, { config, projectId: binding.projectId });
|
|
1094
1204
|
const queryTokens = tokenize(taskQuery);
|
|
1205
|
+
// v2/legacy dedup (M01-05): drop a record whose logical twin
|
|
1206
|
+
// (meta.legacyId → session:<id>, project_id → project:<id>) exists.
|
|
1207
|
+
const legacyIds = new Set(
|
|
1208
|
+
items.filter((item) => item.type !== 'record').map((item) => item.id),
|
|
1209
|
+
);
|
|
1210
|
+
const eligibleCtx = { projectId: binding.projectId, projectAliases: binding.aliases, includeUser: false };
|
|
1095
1211
|
const rankedItems = items
|
|
1096
1212
|
.filter((item) => item.type !== 'user')
|
|
1097
|
-
.filter((item) =>
|
|
1098
|
-
item.type === '
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1213
|
+
.filter((item) => {
|
|
1214
|
+
if (item.type === 'record') {
|
|
1215
|
+
if (!memoryRecordEligible(item.record, eligibleCtx).ok) return false;
|
|
1216
|
+
const legacyKey = recordLegacyKey(item);
|
|
1217
|
+
return !(legacyKey && legacyIds.has(legacyKey));
|
|
1218
|
+
}
|
|
1219
|
+
if (item.type === 'project' || item.type === 'session') {
|
|
1220
|
+
return matchesProjectBinding(item, binding);
|
|
1221
|
+
}
|
|
1222
|
+
return true;
|
|
1223
|
+
})
|
|
1102
1224
|
.map((item) => ({ item, score: scoreMemoryItem(item, queryTokens) }))
|
|
1103
1225
|
.filter((entry) => entry.score > 0)
|
|
1104
1226
|
.sort((left, right) => (
|
|
@@ -1111,8 +1233,17 @@ async function buildPreviousContextLines(projectRoot, state, config) {
|
|
|
1111
1233
|
if (rankedItems.length === 0) {
|
|
1112
1234
|
return [];
|
|
1113
1235
|
}
|
|
1236
|
+
// Freshness is resolved post-limit only — the ≤2 rendered items, never the
|
|
1237
|
+
// pool. Resolver errors degrade to `unknown` and never break the hook.
|
|
1238
|
+
const freshnessById = new Map();
|
|
1239
|
+
await Promise.all(rankedItems.map(async (item) => {
|
|
1240
|
+
if (item.type !== 'record') return;
|
|
1241
|
+
try {
|
|
1242
|
+
freshnessById.set(item.id, await resolveRecordFreshness(item.record, { projectRoot }));
|
|
1243
|
+
} catch { /* absent → unlabeled */ }
|
|
1244
|
+
}));
|
|
1114
1245
|
|
|
1115
|
-
return [`- Previous context: ${rankedItems.map((item) => buildPreviousContextSnippet(item)).join(' | ')}`];
|
|
1246
|
+
return [`- Previous context: ${rankedItems.map((item) => buildPreviousContextSnippet(item, freshnessById.get(item.id))).join(' | ')}`];
|
|
1116
1247
|
}
|
|
1117
1248
|
|
|
1118
1249
|
// BUG-C23-13: only run main() on direct CLI invocation — an unguarded bottom
|