@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
|
@@ -6,29 +6,14 @@ import {
|
|
|
6
6
|
writePromptCacheEntry,
|
|
7
7
|
} from '../token/index.js';
|
|
8
8
|
import { listMemoryItems } from './store.js';
|
|
9
|
-
import { isRecordUsable } from './records.js';
|
|
10
9
|
import { loadV2Records } from './storeV2Loader.js';
|
|
10
|
+
import { POLICY_VERSION, eligible, resolveEffectiveScope } from './policy.js';
|
|
11
11
|
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
]);
|
|
18
|
-
|
|
19
|
-
function normalize(text) {
|
|
20
|
-
return String(text ?? '')
|
|
21
|
-
.toLowerCase()
|
|
22
|
-
.replace(/[^\p{L}\p{N}\s./:_-]/gu, ' ')
|
|
23
|
-
.replace(/\s+/g, ' ')
|
|
24
|
-
.trim();
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function tokenize(text) {
|
|
28
|
-
return normalize(text)
|
|
29
|
-
.split(/\s+/)
|
|
30
|
-
.filter((token) => token && !STOPWORDS.has(token) && token.length > 1);
|
|
31
|
-
}
|
|
12
|
+
import { resolveMemoryStage, appendRolloutReceipt, latencyBandForMs } from './memoryFlags.js';
|
|
13
|
+
import { createIndex, normalize, scoreRecordTokens, tokenize } from './recordIndex.js';
|
|
14
|
+
import { buildMemoryHit, rankHits } from './memoryHit.js';
|
|
15
|
+
import { resolveRecordFreshness } from './memoryFreshness.js';
|
|
16
|
+
import { getHeadSha } from '../codeintel/freshness.js';
|
|
32
17
|
|
|
33
18
|
function compactPhraseList(values, { limit = 1, fallback = 'n/a' } = {}) {
|
|
34
19
|
const normalized = [...new Set(
|
|
@@ -131,21 +116,33 @@ function scoreItem(item, queryTokens) {
|
|
|
131
116
|
return score + recencyBonus;
|
|
132
117
|
}
|
|
133
118
|
|
|
134
|
-
|
|
119
|
+
// projectId + registry-attested aliases (legacy display names recorded at
|
|
120
|
+
// mint time) — migrated v1 records keep the name as project_id (M01-05).
|
|
121
|
+
function boundProjectIds(projectId, projectAliases) {
|
|
122
|
+
const ids = new Set();
|
|
123
|
+
if (typeof projectId === 'string' && projectId) ids.add(projectId);
|
|
124
|
+
for (const alias of projectAliases ?? []) {
|
|
125
|
+
if (typeof alias === 'string' && alias) ids.add(alias);
|
|
126
|
+
}
|
|
127
|
+
return ids;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function withinScope(item, scope, projectId, projectAliases) {
|
|
135
131
|
if (scope !== 'all' && item.type !== scope) {
|
|
136
132
|
return false;
|
|
137
133
|
}
|
|
138
134
|
|
|
139
|
-
|
|
135
|
+
const boundIds = boundProjectIds(projectId, projectAliases);
|
|
136
|
+
if (boundIds.size === 0) {
|
|
140
137
|
return true;
|
|
141
138
|
}
|
|
142
139
|
|
|
143
140
|
if (item.type === 'project') {
|
|
144
|
-
return item.content?.id
|
|
141
|
+
return boundIds.has(item.content?.id);
|
|
145
142
|
}
|
|
146
143
|
|
|
147
144
|
if (item.type === 'session') {
|
|
148
|
-
return item.content?.projectId
|
|
145
|
+
return boundIds.has(item.content?.projectId);
|
|
149
146
|
}
|
|
150
147
|
|
|
151
148
|
return true;
|
|
@@ -164,109 +161,205 @@ function buildSearchResult(item, relevanceScore) {
|
|
|
164
161
|
|
|
165
162
|
// ---- Memory v2 records path (SPEC §2–§3) ----
|
|
166
163
|
|
|
167
|
-
const RECORD_TYPE_WEIGHTS = Object.freeze({
|
|
164
|
+
export const RECORD_TYPE_WEIGHTS = Object.freeze({
|
|
168
165
|
project_rule: 7,
|
|
169
166
|
derived_fact: 5,
|
|
170
167
|
procedure: 5,
|
|
171
168
|
episode: 3,
|
|
172
169
|
});
|
|
173
170
|
|
|
174
|
-
// Lazy import: storeV2.js is owned by TASK-002;
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
171
|
+
// Lazy import: storeV2.js is owned by TASK-002; the loader reports a typed
|
|
172
|
+
// {state, records} result. Legacy fallback is allowed ONLY on 'missing' or
|
|
173
|
+
// 'empty' — 'corrupt'|'disabled'|'migrating' mean the v2 lane is authoritative
|
|
174
|
+
// and empty (FR-008). queryRecords itself triggers ensureMigrated on the real
|
|
175
|
+
// store — no explicit migration here.
|
|
176
|
+
async function queryV2Records(projectRoot, { projectId } = {}) {
|
|
177
|
+
let policy = {};
|
|
178
|
+
let config = null;
|
|
178
179
|
try {
|
|
179
|
-
|
|
180
|
+
config = await loadRuntimeConfig(projectRoot);
|
|
180
181
|
if (config?.memoryV2?.enabled === false) {
|
|
181
|
-
return [];
|
|
182
|
+
return { state: 'disabled', records: [], policy, generation: 0 };
|
|
183
|
+
}
|
|
184
|
+
if (config?.memoryV2?.policy && typeof config.memoryV2.policy === 'object') {
|
|
185
|
+
policy = config.memoryV2.policy;
|
|
182
186
|
}
|
|
183
187
|
} catch {
|
|
184
|
-
// unreadable config → default enabled, keep going
|
|
185
|
-
}
|
|
186
|
-
|
|
188
|
+
// unreadable config → default enabled, default policy, keep going
|
|
189
|
+
}
|
|
190
|
+
// M06 rollout flag (SPEC §5 FR-003): eligibility stage gates the v2 read
|
|
191
|
+
// lane. 'off' (incl. killSwitch / unlisted canary) → legacy lane output,
|
|
192
|
+
// byte-identical. 'shadow' runs the v2 load for measurement, discards it,
|
|
193
|
+
// emits a receipt, and still serves the legacy lane.
|
|
194
|
+
const eligibilityStage = config
|
|
195
|
+
? resolveMemoryStage(config, 'eligibility', { projectId })
|
|
196
|
+
: 'default';
|
|
197
|
+
if (eligibilityStage === 'off') {
|
|
198
|
+
return { state: 'missing', records: [], policy, generation: 0 };
|
|
199
|
+
}
|
|
200
|
+
if (eligibilityStage === 'shadow') {
|
|
201
|
+
const startedAt = Date.now();
|
|
202
|
+
const probe = await loadV2Records(projectRoot, {});
|
|
203
|
+
await appendRolloutReceipt(projectRoot, {
|
|
204
|
+
plane: 'eligibility',
|
|
205
|
+
stage: 'shadow',
|
|
206
|
+
outcome: probe.state === 'ok' ? 'ok' : 'error',
|
|
207
|
+
code: probe.state,
|
|
208
|
+
latencyBand: latencyBandForMs(Date.now() - startedAt),
|
|
209
|
+
});
|
|
210
|
+
return { state: 'missing', records: [], policy, generation: 0 };
|
|
211
|
+
}
|
|
212
|
+
const { state, records, generation } = await loadV2Records(projectRoot, {}, { config, projectId });
|
|
213
|
+
// Index plane (FR-003): 'off' → linear scan; 'shadow' → indexed path runs
|
|
214
|
+
// for measurement only, linear result is served; live → indexed path.
|
|
215
|
+
const indexStage = config
|
|
216
|
+
? resolveMemoryStage(config, 'index', { projectId })
|
|
217
|
+
: 'default';
|
|
218
|
+
return { state, records, policy, generation: generation ?? 0, indexStage };
|
|
187
219
|
}
|
|
188
220
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const recordTokens = new Set(tokenize(`${record.text ?? ''} ${record.provenance ?? ''}`));
|
|
196
|
-
let matched = 0;
|
|
197
|
-
for (const token of queryTokens) {
|
|
198
|
-
if (recordTokens.has(token)) {
|
|
199
|
-
matched += 1;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
if (matched === 0) {
|
|
203
|
-
return 0;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const recencyBonus = record.created_at > 0
|
|
207
|
-
? Math.min(1, record.created_at / Date.now())
|
|
208
|
-
: 0;
|
|
209
|
-
return typeWeight * matched * Math.max(0.1, record.confidence ?? 1) + recencyBonus;
|
|
221
|
+
// 'missing'|'empty' → legacy compat lane; every other state keeps the v2 lane
|
|
222
|
+
// authoritative (deny-by-default, no silent legacy injection).
|
|
223
|
+
function v2AllowsLegacyFallback(state) {
|
|
224
|
+
return state === 'missing' || state === 'empty';
|
|
210
225
|
}
|
|
211
226
|
|
|
212
227
|
// Legacy scope names ('user'|'project'|'session') map onto v2 scope/type:
|
|
213
228
|
// 'project' ↔ scope 'repo'/'task', 'session' ↔ type 'episode'.
|
|
214
|
-
const LEGACY_SCOPE_MATCH = {
|
|
229
|
+
export const LEGACY_SCOPE_MATCH = {
|
|
215
230
|
user: (record) => record.scope === 'user',
|
|
216
231
|
project: (record) => record.scope === 'repo' || record.scope === 'task',
|
|
217
232
|
session: (record) => record.type === 'episode' || record.scope === 'session',
|
|
218
233
|
};
|
|
219
234
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
return true;
|
|
235
|
+
// TASK-003 (FR-005): both v2 search paths delegate ranking to rankHits.
|
|
236
|
+
// rankHits includes bonus-only (lexical=0) records; the index path can only
|
|
237
|
+
// surface token-matching candidates, so the scan path pre-filters to the
|
|
238
|
+
// same candidate pool (scoreRecordTokens > 0) — identical hits either way.
|
|
239
|
+
function searchRecords(records, query, scope, limit, eligibleCtx, policy) {
|
|
240
|
+
const queryTokens = tokenize(query);
|
|
241
|
+
const candidates = records.filter((record) => scoreRecordTokens(record, queryTokens) > 0);
|
|
242
|
+
return rankHits(candidates, { queryTokens, eligibleCtx, policy, scope, limit });
|
|
230
243
|
}
|
|
231
244
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
245
|
+
// Freshness is resolved post-limit only (≤ limit × MAX_EVIDENCE stats per
|
|
246
|
+
// call). Watermarks are caller-supplied; when omitted and a hit carries
|
|
247
|
+
// non-file evidence, `command` is filled lazily via getHeadSha (one bounded
|
|
248
|
+
// git spawn per call, never per record). Resolver errors degrade to
|
|
249
|
+
// 'unknown' and never break search.
|
|
250
|
+
async function attachFreshness(hits, records, { projectRoot, watermarks } = {}) {
|
|
251
|
+
if (!Array.isArray(hits) || hits.length === 0) return hits;
|
|
252
|
+
const recordById = new Map((records ?? []).map((record) => [record.id, record]));
|
|
253
|
+
const needsCommand = watermarks?.command == null && hits.some((hit) => {
|
|
254
|
+
const record = recordById.get(hit.id);
|
|
255
|
+
return Array.isArray(record?.evidence)
|
|
256
|
+
&& record.evidence.some((entry) => entry != null && entry.kind !== 'file');
|
|
257
|
+
});
|
|
258
|
+
const resolvedWatermarks = { ...(watermarks ?? {}) };
|
|
259
|
+
if (needsCommand) {
|
|
260
|
+
try {
|
|
261
|
+
resolvedWatermarks.command = getHeadSha(projectRoot);
|
|
262
|
+
} catch { /* absent → unknown */ }
|
|
263
|
+
}
|
|
264
|
+
await Promise.all(hits.map(async (hit) => {
|
|
265
|
+
const record = recordById.get(hit.id);
|
|
266
|
+
hit.freshness = await resolveRecordFreshness(record, {
|
|
267
|
+
projectRoot,
|
|
268
|
+
watermarks: resolvedWatermarks,
|
|
269
|
+
});
|
|
270
|
+
}));
|
|
271
|
+
return hits;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Legacy requested scopes map onto policy scopes: 'project' is repo-bound.
|
|
275
|
+
const REQUESTED_SCOPE_TO_POLICY = { project: 'repo' };
|
|
276
|
+
|
|
277
|
+
// TASK-003 (FR-006/FR-007): module-scoped inverted index, in-process only,
|
|
278
|
+
// never persisted. Cache key = projectRoot + project identity + POLICY_VERSION;
|
|
279
|
+
// validity additionally requires index.generation === store generation. Any
|
|
280
|
+
// mismatch, absence, or error falls back to the lexical scan — the index
|
|
281
|
+
// narrows candidates, rankHits re-gates + scores (identical output either
|
|
282
|
+
// path: the index candidate pool is exactly the score>0 set the scan uses).
|
|
283
|
+
// Module-scoped index cache (in-process only, never persisted).
|
|
284
|
+
const recordIndexes = new Map();
|
|
285
|
+
|
|
286
|
+
export function resetRecordIndexForTests() {
|
|
287
|
+
recordIndexes.clear();
|
|
241
288
|
}
|
|
242
289
|
|
|
243
|
-
function
|
|
290
|
+
function searchRecordsIndexed(records, query, scope, limit, eligibleCtx, policy, indexKey, generation) {
|
|
244
291
|
const queryTokens = tokenize(query);
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
))
|
|
255
|
-
|
|
256
|
-
|
|
292
|
+
if (queryTokens.length === 0) {
|
|
293
|
+
return searchRecords(records, query, scope, limit, eligibleCtx, policy);
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
let index = recordIndexes.get(indexKey);
|
|
297
|
+
if (!index) {
|
|
298
|
+
index = createIndex();
|
|
299
|
+
recordIndexes.set(indexKey, index);
|
|
300
|
+
}
|
|
301
|
+
if (index.stats().generation !== generation) {
|
|
302
|
+
index.build(records, { generation });
|
|
303
|
+
}
|
|
304
|
+
const candidates = index.query(queryTokens, { limit: records.length })
|
|
305
|
+
.map((hit) => hit.record);
|
|
306
|
+
return rankHits(candidates, { queryTokens, eligibleCtx, policy, scope, limit });
|
|
307
|
+
} catch {
|
|
308
|
+
return searchRecords(records, query, scope, limit, eligibleCtx, policy);
|
|
309
|
+
}
|
|
257
310
|
}
|
|
258
311
|
|
|
259
|
-
export async function search(query, scope = 'all', limit = 10, { projectRoot, projectId } = {}) {
|
|
260
|
-
const records = await queryV2Records(projectRoot);
|
|
261
|
-
|
|
262
|
-
|
|
312
|
+
export async function search(query, scope = 'all', limit = 10, { projectRoot, projectId, projectAliases, index = true, watermarks } = {}) {
|
|
313
|
+
const { state, records, policy, generation, indexStage } = await queryV2Records(projectRoot, { projectId });
|
|
314
|
+
const effective = resolveEffectiveScope(
|
|
315
|
+
{ projectId: projectId ?? null },
|
|
316
|
+
REQUESTED_SCOPE_TO_POLICY[scope] ?? scope,
|
|
317
|
+
);
|
|
318
|
+
if (effective.denied) {
|
|
319
|
+
return [];
|
|
320
|
+
}
|
|
321
|
+
const eligibleCtx = {
|
|
322
|
+
projectId: effective.projectId,
|
|
323
|
+
projectAliases: projectAliases ?? [],
|
|
324
|
+
includeUser: effective.includeUser,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
if (!v2AllowsLegacyFallback(state)) {
|
|
328
|
+
const indexKey = `${projectRoot ?? ''}::${effective.projectId ?? ''}::${POLICY_VERSION}`;
|
|
329
|
+
// Index plane (FR-003): 'off' forces the linear scan; 'shadow' runs the
|
|
330
|
+
// indexed path for measurement, discards it, and serves the scan result
|
|
331
|
+
// (zero output change) plus a bounded receipt.
|
|
332
|
+
if (index !== false && indexStage === 'shadow') {
|
|
333
|
+
const startedAt = Date.now();
|
|
334
|
+
try {
|
|
335
|
+
searchRecordsIndexed(records, query, scope, limit, eligibleCtx, policy, indexKey, generation);
|
|
336
|
+
await appendRolloutReceipt(projectRoot, {
|
|
337
|
+
plane: 'index', stage: 'shadow', outcome: 'ok', code: 'search',
|
|
338
|
+
latencyBand: latencyBandForMs(Date.now() - startedAt),
|
|
339
|
+
});
|
|
340
|
+
} catch {
|
|
341
|
+
await appendRolloutReceipt(projectRoot, {
|
|
342
|
+
plane: 'index', stage: 'shadow', outcome: 'error', code: 'search',
|
|
343
|
+
latencyBand: latencyBandForMs(Date.now() - startedAt),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const hits = index === false || indexStage === 'off' || indexStage === 'shadow'
|
|
348
|
+
? searchRecords(records, query, scope, limit, eligibleCtx, policy)
|
|
349
|
+
: searchRecordsIndexed(
|
|
350
|
+
records, query, scope, limit, eligibleCtx, policy,
|
|
351
|
+
indexKey,
|
|
352
|
+
generation,
|
|
353
|
+
);
|
|
354
|
+
return attachFreshness(hits, records, { projectRoot, watermarks });
|
|
263
355
|
}
|
|
264
356
|
|
|
265
357
|
const items = await listMemoryItems(projectRoot);
|
|
266
358
|
const queryTokens = tokenize(query);
|
|
267
359
|
|
|
360
|
+
|
|
268
361
|
return items
|
|
269
|
-
.filter((item) => withinScope(item, scope, projectId))
|
|
362
|
+
.filter((item) => withinScope(item, scope, projectId, projectAliases))
|
|
270
363
|
.map((item) => ({
|
|
271
364
|
item,
|
|
272
365
|
score: scoreItem(item, queryTokens),
|
|
@@ -309,9 +402,15 @@ function findRelatedItemIds(targetItem, allItems) {
|
|
|
309
402
|
.slice(0, 5);
|
|
310
403
|
}
|
|
311
404
|
|
|
312
|
-
export async function expand(id, { projectRoot } = {}) {
|
|
313
|
-
const records = await queryV2Records(projectRoot);
|
|
314
|
-
if (
|
|
405
|
+
export async function expand(id, { projectRoot, projectId, projectAliases } = {}) {
|
|
406
|
+
const { state, records, policy } = await queryV2Records(projectRoot, { projectId });
|
|
407
|
+
if (!v2AllowsLegacyFallback(state)) {
|
|
408
|
+
const effective = resolveEffectiveScope({ projectId: projectId ?? null }, 'all');
|
|
409
|
+
const eligibleCtx = {
|
|
410
|
+
projectId: effective.projectId,
|
|
411
|
+
projectAliases: projectAliases ?? [],
|
|
412
|
+
includeUser: effective.includeUser,
|
|
413
|
+
};
|
|
315
414
|
// Resolve legacy item ids too: 'session:<legacyId>' → episode with
|
|
316
415
|
// meta.legacyId, 'project:<projectId>' → records of that project.
|
|
317
416
|
const colonIdx = String(id).indexOf(':');
|
|
@@ -326,12 +425,15 @@ export async function expand(id, { projectRoot } = {}) {
|
|
|
326
425
|
if (!target) {
|
|
327
426
|
return { id, fullContent: '', relatedItems: [] };
|
|
328
427
|
}
|
|
428
|
+
const [hit] = await attachFreshness([buildMemoryHit(target)], [target], { projectRoot });
|
|
329
429
|
return {
|
|
330
430
|
id: target.id,
|
|
431
|
+
hit,
|
|
331
432
|
fullContent: JSON.stringify(target, null, 2),
|
|
332
433
|
relatedItems: records
|
|
333
434
|
.filter((record) => record.id !== target.id)
|
|
334
435
|
.filter((record) => record.type === target.type || record.project_id === target.project_id)
|
|
436
|
+
.filter((record) => eligible(record, eligibleCtx, policy).ok)
|
|
335
437
|
.map((record) => record.id)
|
|
336
438
|
.slice(0, 5),
|
|
337
439
|
};
|
|
@@ -389,9 +491,6 @@ function buildInjectionSnippet(item) {
|
|
|
389
491
|
return `user-rules=${(content.rules ?? []).slice(0, 3).join('; ') || 'n/a'}`;
|
|
390
492
|
}
|
|
391
493
|
|
|
392
|
-
function includeUserMemoryOrRecord(includeUserMemory, record) {
|
|
393
|
-
return includeUserMemory || record.scope !== 'user';
|
|
394
|
-
}
|
|
395
494
|
|
|
396
495
|
function buildRecordSnippet(record) {
|
|
397
496
|
const text = String(record.text ?? '').trim();
|
|
@@ -418,14 +517,6 @@ function resolveRecallPressurePlan({
|
|
|
418
517
|
};
|
|
419
518
|
}
|
|
420
519
|
|
|
421
|
-
if (phase === 'soft') {
|
|
422
|
-
return {
|
|
423
|
-
phase,
|
|
424
|
-
maxTokens: Math.min(maxTokens, Math.max(112, Math.round(maxTokens * 0.88))),
|
|
425
|
-
limit: Math.max(1, Math.min(limit, 4)),
|
|
426
|
-
includeUserMemory: true,
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
520
|
|
|
430
521
|
return {
|
|
431
522
|
phase,
|
|
@@ -435,14 +526,17 @@ function resolveRecallPressurePlan({
|
|
|
435
526
|
};
|
|
436
527
|
}
|
|
437
528
|
|
|
438
|
-
function isProjectAnchor(item,
|
|
439
|
-
return Boolean(item && item.type === 'project'
|
|
529
|
+
function isProjectAnchor(item, boundIds) {
|
|
530
|
+
return Boolean(item && item.type === 'project'
|
|
531
|
+
&& (boundIds.size === 0 || boundIds.has(item.content?.id)));
|
|
440
532
|
}
|
|
441
533
|
|
|
442
|
-
function isSessionAnchor(item,
|
|
443
|
-
return Boolean(item && item.type === 'session'
|
|
534
|
+
function isSessionAnchor(item, boundIds) {
|
|
535
|
+
return Boolean(item && item.type === 'session'
|
|
536
|
+
&& (boundIds.size === 0 || boundIds.has(item.content?.projectId)));
|
|
444
537
|
}
|
|
445
538
|
|
|
539
|
+
|
|
446
540
|
function dedupeItemsById(items) {
|
|
447
541
|
const seen = new Set();
|
|
448
542
|
const unique = [];
|
|
@@ -460,10 +554,12 @@ function selectContextItems({
|
|
|
460
554
|
items = [],
|
|
461
555
|
rankedItems = [],
|
|
462
556
|
projectId,
|
|
557
|
+
projectAliases = [],
|
|
463
558
|
limit = 5,
|
|
464
559
|
includeUserMemory = true,
|
|
465
560
|
allowProjectFallback = false,
|
|
466
561
|
} = {}) {
|
|
562
|
+
const boundIds = boundProjectIds(projectId, projectAliases);
|
|
467
563
|
const itemById = new Map(items.map((item) => [item.id, item]));
|
|
468
564
|
const rankedSelection = rankedItems
|
|
469
565
|
.map((entry) => itemById.get(entry.id))
|
|
@@ -472,13 +568,13 @@ function selectContextItems({
|
|
|
472
568
|
if (rankedSelection.length === 0 && !allowProjectFallback) {
|
|
473
569
|
return [];
|
|
474
570
|
}
|
|
475
|
-
const projectAnchor =
|
|
476
|
-
? items.find((item) => item.type === 'project' && item.content?.id
|
|
571
|
+
const projectAnchor = boundIds.size > 0
|
|
572
|
+
? items.find((item) => item.type === 'project' && boundIds.has(item.content?.id))
|
|
477
573
|
: rankedSelection.find((item) => item.type === 'project');
|
|
478
574
|
const prioritized = dedupeItemsById([
|
|
479
575
|
rankedSelection.length > 0 || allowProjectFallback ? projectAnchor : null,
|
|
480
|
-
...rankedSelection.filter((item) => isSessionAnchor(item,
|
|
481
|
-
...rankedSelection.filter((item) => isProjectAnchor(item,
|
|
576
|
+
...rankedSelection.filter((item) => isSessionAnchor(item, boundIds)),
|
|
577
|
+
...rankedSelection.filter((item) => isProjectAnchor(item, boundIds) && item.id !== projectAnchor?.id),
|
|
482
578
|
...rankedSelection.filter((item) => item.type !== 'session' && item.type !== 'project'),
|
|
483
579
|
]);
|
|
484
580
|
|
|
@@ -493,6 +589,7 @@ function buildContextRequestKey({
|
|
|
493
589
|
inputCompression,
|
|
494
590
|
selectedItems,
|
|
495
591
|
pressurePhase,
|
|
592
|
+
freshnessById,
|
|
496
593
|
}) {
|
|
497
594
|
return buildCompactMachineKey('memory-recall-v1', {
|
|
498
595
|
currentTask: normalize(currentTask),
|
|
@@ -501,10 +598,17 @@ function buildContextRequestKey({
|
|
|
501
598
|
limit,
|
|
502
599
|
inputCompression,
|
|
503
600
|
pressurePhase,
|
|
601
|
+
policyVersion: POLICY_VERSION,
|
|
504
602
|
items: selectedItems.map((item) => ({
|
|
505
603
|
id: item.id,
|
|
506
604
|
timestamp: getTimestamp(item),
|
|
507
605
|
})),
|
|
606
|
+
// FR-006: freshness signature — a stale transition changes the key so a
|
|
607
|
+
// cached "fresh" block is never served after evidence changed.
|
|
608
|
+
freshness: selectedItems.map((item) => ({
|
|
609
|
+
id: item.id,
|
|
610
|
+
state: freshnessById?.get(item.id)?.state ?? null,
|
|
611
|
+
})),
|
|
508
612
|
});
|
|
509
613
|
}
|
|
510
614
|
|
|
@@ -517,27 +621,31 @@ export async function getContextInjection(
|
|
|
517
621
|
limit = 5,
|
|
518
622
|
promptCache = false,
|
|
519
623
|
inputCompression = true,
|
|
624
|
+
projectAliases = [],
|
|
520
625
|
} = {},
|
|
521
626
|
) {
|
|
522
|
-
const records = await queryV2Records(projectRoot);
|
|
523
|
-
const
|
|
627
|
+
const { state, records, policy } = await queryV2Records(projectRoot, { projectId });
|
|
628
|
+
const v2Active = !v2AllowsLegacyFallback(state);
|
|
629
|
+
const items = v2Active ? [] : await listMemoryItems(projectRoot);
|
|
524
630
|
const pressureState = await readCompactPressureState(projectRoot);
|
|
525
631
|
const recallPlan = resolveRecallPressurePlan({
|
|
526
632
|
maxTokens,
|
|
527
633
|
limit,
|
|
528
634
|
pressureState,
|
|
529
635
|
});
|
|
530
|
-
const rankedItems = await search(currentTask, 'all', Math.max(limit * 3, 8), { projectRoot, projectId });
|
|
531
|
-
const
|
|
636
|
+
const rankedItems = await search(currentTask, 'all', Math.max(limit * 3, 8), { projectRoot, projectId, projectAliases });
|
|
637
|
+
const eligibleCtx = { projectId: projectId ?? null, projectAliases, includeUser: recallPlan.includeUserMemory };
|
|
638
|
+
const selectedItems = v2Active
|
|
532
639
|
? dedupeItemsById(rankedItems
|
|
533
640
|
.map((entry) => records.find((record) => record.id === entry.id))
|
|
534
641
|
.filter(Boolean)
|
|
535
|
-
.filter((record) =>
|
|
642
|
+
.filter((record) => eligible(record, eligibleCtx, policy).ok))
|
|
536
643
|
.slice(0, Math.max(1, recallPlan.limit))
|
|
537
644
|
: selectContextItems({
|
|
538
645
|
items,
|
|
539
646
|
rankedItems,
|
|
540
647
|
projectId,
|
|
648
|
+
projectAliases,
|
|
541
649
|
limit: recallPlan.limit,
|
|
542
650
|
includeUserMemory: recallPlan.includeUserMemory,
|
|
543
651
|
allowProjectFallback: !String(currentTask ?? '').trim(),
|
|
@@ -547,6 +655,28 @@ export async function getContextInjection(
|
|
|
547
655
|
return '';
|
|
548
656
|
}
|
|
549
657
|
|
|
658
|
+
// FR-005/FR-006: freshness resolved post-selection only (≤ limit records),
|
|
659
|
+
// before the request key so a stale transition busts the prompt cache.
|
|
660
|
+
// Watermarks: command → getHeadSha once per call (bounded, never-throw);
|
|
661
|
+
// no cheap ledger-generation accessor exists → ledger omitted → unknown.
|
|
662
|
+
const freshnessById = new Map();
|
|
663
|
+
if (v2Active) {
|
|
664
|
+
const needsCommand = selectedItems.some((record) => Array.isArray(record?.evidence)
|
|
665
|
+
&& record.evidence.some((entry) => entry != null && entry.kind !== 'file'));
|
|
666
|
+
const watermarks = {};
|
|
667
|
+
if (needsCommand) {
|
|
668
|
+
try {
|
|
669
|
+
watermarks.command = getHeadSha(projectRoot);
|
|
670
|
+
} catch { /* absent → unknown */ }
|
|
671
|
+
}
|
|
672
|
+
await Promise.all(selectedItems.map(async (record) => {
|
|
673
|
+
freshnessById.set(record.id, await resolveRecordFreshness(record, {
|
|
674
|
+
projectRoot,
|
|
675
|
+
watermarks,
|
|
676
|
+
}));
|
|
677
|
+
}));
|
|
678
|
+
}
|
|
679
|
+
|
|
550
680
|
const requestKey = buildContextRequestKey({
|
|
551
681
|
currentTask,
|
|
552
682
|
projectId,
|
|
@@ -555,6 +685,7 @@ export async function getContextInjection(
|
|
|
555
685
|
inputCompression,
|
|
556
686
|
selectedItems,
|
|
557
687
|
pressurePhase: recallPlan.phase,
|
|
688
|
+
freshnessById,
|
|
558
689
|
});
|
|
559
690
|
|
|
560
691
|
if (promptCache) {
|
|
@@ -564,11 +695,14 @@ export async function getContextInjection(
|
|
|
564
695
|
}
|
|
565
696
|
}
|
|
566
697
|
|
|
567
|
-
|
|
568
|
-
|
|
698
|
+
// FR-005: ' [stale]' appended only when freshness resolves stale; unknown
|
|
699
|
+
// adds no token.
|
|
700
|
+
const staleTag = (record) => (freshnessById.get(record.id)?.state === 'stale' ? ' [stale]' : '');
|
|
701
|
+
const rawBodyLines = v2Active
|
|
702
|
+
? selectedItems.map((record) => `- [${record.type}] ${buildRecordSnippet(record)}${staleTag(record)}`)
|
|
569
703
|
: selectedItems.map((item) => `- [${item.type}] ${buildDetailedInjectionSnippet(item)}`);
|
|
570
|
-
const bodyLines =
|
|
571
|
-
? selectedItems.map((record) => `- ${buildRecordSnippet(record)}`)
|
|
704
|
+
const bodyLines = v2Active
|
|
705
|
+
? selectedItems.map((record) => `- ${buildRecordSnippet(record)}${staleTag(record)}`)
|
|
572
706
|
: selectedItems.map((item) => `- ${buildInjectionSnippet(item)}`);
|
|
573
707
|
const rawLines = ['## Previous Context', ...rawBodyLines];
|
|
574
708
|
const rawText = rawLines.join('\n');
|
package/src/core/memory/store.js
CHANGED
|
@@ -5,6 +5,7 @@ import { buildRuntimePaths } from '../runtimePaths.js';
|
|
|
5
5
|
import { readJsonIfExists, writeJson } from '../fileOps.js';
|
|
6
6
|
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
7
7
|
import { runHygiene } from './hygiene.js';
|
|
8
|
+
import { redactWritePayload } from './writeGuard.js';
|
|
8
9
|
|
|
9
10
|
// ---- Memory v2 facade (SPEC §3/§11) ----
|
|
10
11
|
// Every exported signature and CLI output shape is preserved. When the v2
|
|
@@ -272,22 +273,26 @@ function createSearchableText(type, content) {
|
|
|
272
273
|
|
|
273
274
|
export async function exportMemory(projectRoot) {
|
|
274
275
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
276
|
+
// FR-023/025: export is a bulk read lane — redact at the store boundary so
|
|
277
|
+
// every caller (CLI, future lanes) gets defense-in-depth, not just the CLI.
|
|
278
|
+
const config = await loadRuntimeConfig(projectRoot);
|
|
279
|
+
const redact = (data) => redactWritePayload({ meta: data }, { config }).payload.meta;
|
|
275
280
|
if (await v2Active(projectRoot)) {
|
|
276
281
|
const v2 = await storeV2();
|
|
277
282
|
const items = recordsToMemoryItems(await v2.loadRecords(projectRoot), runtimePaths);
|
|
278
283
|
const user = items.find((item) => item.type === 'user')?.content ?? defaultUserMemory();
|
|
279
|
-
return {
|
|
284
|
+
return redact({
|
|
280
285
|
user,
|
|
281
286
|
projects: items.filter((item) => item.type === 'project').map((item) => item.content),
|
|
282
287
|
sessions: items.filter((item) => item.type === 'session').map((item) => item.content),
|
|
283
|
-
};
|
|
288
|
+
});
|
|
284
289
|
}
|
|
285
290
|
|
|
286
291
|
const user = (await readMemoryJson(runtimePaths.userMemoryPath)) ?? defaultUserMemory();
|
|
287
292
|
const projects = (await readDirectoryJsonItems(runtimePaths.projectsDir)).map((item) => item.content);
|
|
288
293
|
const sessions = (await readDirectoryJsonItems(runtimePaths.sessionsDir)).map((item) => item.content);
|
|
289
294
|
|
|
290
|
-
return { user, projects, sessions };
|
|
295
|
+
return redact({ user, projects, sessions });
|
|
291
296
|
}
|
|
292
297
|
|
|
293
298
|
// Read-compat: corrupt legacy files still surface the same warning the legacy
|
|
@@ -352,12 +357,16 @@ export async function listMemoryItems(projectRoot) {
|
|
|
352
357
|
|
|
353
358
|
async function forgetMemoryItemV2(projectRoot, memoryId, runtimePaths) {
|
|
354
359
|
const v2 = await storeV2();
|
|
360
|
+
const { mutateMemory } = await import('./mutateMemory.js');
|
|
355
361
|
const archiveAll = async (records) => {
|
|
356
362
|
let archived = 0;
|
|
357
363
|
for (const record of records) {
|
|
358
364
|
if (record.status !== 'archived') {
|
|
359
|
-
|
|
360
|
-
|
|
365
|
+
const res = await mutateMemory(
|
|
366
|
+
{ kind: 'project', projectRoot },
|
|
367
|
+
{ op: 'forget', payload: { id: record.id } },
|
|
368
|
+
);
|
|
369
|
+
if (res.status === 'ok') archived += 1;
|
|
361
370
|
}
|
|
362
371
|
}
|
|
363
372
|
return archived;
|
|
@@ -641,6 +650,8 @@ async function resolvePatternCandidateV2(projectRoot, projectId, candidateId, de
|
|
|
641
650
|
scope: 'repo',
|
|
642
651
|
confidence: 0.9,
|
|
643
652
|
provenance: `${target.provenance ?? 'pattern-candidate'};promoted-from:${target.id}`,
|
|
653
|
+
// Approval is a trust transition — the promoted record is authoritative.
|
|
654
|
+
trust_tier: 'verified',
|
|
644
655
|
meta: {
|
|
645
656
|
...target.meta,
|
|
646
657
|
legacyStatus: 'approved',
|