@ngockhoale/ukit 2.6.6 → 2.6.8
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 +37 -0
- package/README.md +40 -177
- package/manifests/documentation.yaml +143 -15
- package/manifests/hostCapabilities.yaml +49 -0
- package/manifests/instructionRules.yaml +383 -0
- package/manifests/platform.full.yaml +15 -0
- package/package.json +3 -1
- package/scripts/bench/goldTasks.json +38 -0
- package/scripts/bench/runGold.mjs +220 -0
- package/scripts/docs/render-instructions.mjs +42 -0
- package/scripts/release/verify-release.mjs +6 -0
- package/src/cli/commands/code.js +182 -0
- package/src/cli/commands/doctor.js +35 -3
- package/src/cli/commands/indexTools.js +102 -1
- package/src/cli/commands/memory.js +137 -0
- package/src/cli/index.js +7 -0
- package/src/core/codeintel/compiler.js +316 -0
- package/src/core/codeintel/diagnostics.js +114 -0
- package/src/core/codeintel/freshness.js +295 -0
- package/src/core/codeintel/impact.js +251 -0
- package/src/core/codeintel/invalidation.js +150 -0
- package/src/core/codeintel/manifest.js +176 -0
- package/src/core/codeintel/packet.js +146 -0
- package/src/core/codeintel/providers.js +201 -0
- package/src/core/codeintel/retriever.js +372 -0
- package/src/core/codeintel/router.js +149 -0
- package/src/core/codeintel/semanticProvider.js +235 -0
- package/src/core/docContracts.js +723 -0
- package/src/core/memory/migrate.js +324 -0
- package/src/core/memory/records.js +172 -0
- package/src/core/memory/retrieval.js +161 -11
- package/src/core/memory/store.js +398 -0
- package/src/core/memory/storeV2.js +171 -0
- package/src/core/memory/storeV2Loader.js +22 -0
- package/src/core/projectImportant.js +1 -1
- package/src/core/runtimeConfig.js +125 -0
- package/src/core/runtimePaths.js +3 -0
- package/src/core/uninstall.js +1 -1
- package/src/index/taskRouting.js +39 -0
- package/src/render/instructionRenderer.js +226 -0
- package/templates/.claude/ukit/index/route-task.mjs +40 -0
- package/templates/.gitignore +2 -2
- package/templates/.omp/RULES.md +1 -0
- package/templates/AGENTS.md +89 -218
- package/templates/CLAUDE.md +85 -212
- package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +5 -0
- package/templates/docs/BUGFIX.md +2 -19
- package/templates/docs/BUG_INDEX.md +43 -0
- package/templates/docs/BUG_METRICS.md +1 -5
- package/templates/docs/BUG_TEMPLATE.md +1 -11
- package/templates/docs/UKIT_INTERNALS.md +223 -0
- package/templates/instructions/core.md +157 -0
- package/templates/instructions/layout.yaml +149 -0
- package/templates/instructions/overlays/agents.md +15 -0
- package/templates/instructions/overlays/claude.md +3 -0
- package/templates/instructions/overlays/omp-rules.md +74 -0
- package/templates/instructions/overlays/repo.md +9 -0
- package/templates/instructions/repo-vars.yaml +23 -0
- package/templates/ukit/storage/config.json +30 -0
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
writePromptCacheEntry,
|
|
7
7
|
} from '../token/index.js';
|
|
8
8
|
import { listMemoryItems } from './store.js';
|
|
9
|
+
import { isRecordUsable } from './records.js';
|
|
10
|
+
import { loadV2Records } from './storeV2Loader.js';
|
|
11
|
+
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
9
12
|
|
|
10
13
|
const STOPWORDS = new Set([
|
|
11
14
|
'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
|
|
@@ -83,6 +86,7 @@ function getTimestamp(item) {
|
|
|
83
86
|
return item.content?.updatedAt
|
|
84
87
|
?? item.content?.endedAt
|
|
85
88
|
?? item.content?.startedAt
|
|
89
|
+
?? item.created_at
|
|
86
90
|
?? 0;
|
|
87
91
|
}
|
|
88
92
|
|
|
@@ -158,7 +162,106 @@ function buildSearchResult(item, relevanceScore) {
|
|
|
158
162
|
};
|
|
159
163
|
}
|
|
160
164
|
|
|
165
|
+
// ---- Memory v2 records path (SPEC §2–§3) ----
|
|
166
|
+
|
|
167
|
+
const RECORD_TYPE_WEIGHTS = Object.freeze({
|
|
168
|
+
project_rule: 7,
|
|
169
|
+
derived_fact: 5,
|
|
170
|
+
procedure: 5,
|
|
171
|
+
episode: 3,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Lazy import: storeV2.js is owned by TASK-002; missing module or a disabled
|
|
175
|
+
// memoryV2 config falls back to the legacy items path. queryRecords itself
|
|
176
|
+
// triggers ensureMigrated on the real store — no explicit migration here.
|
|
177
|
+
async function queryV2Records(projectRoot) {
|
|
178
|
+
try {
|
|
179
|
+
const config = await loadRuntimeConfig(projectRoot);
|
|
180
|
+
if (config?.memoryV2?.enabled === false) {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
// unreadable config → default enabled, keep going
|
|
185
|
+
}
|
|
186
|
+
return loadV2Records(projectRoot, {});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function scoreRecord(record, queryTokens) {
|
|
190
|
+
const typeWeight = RECORD_TYPE_WEIGHTS[record.type] ?? 1;
|
|
191
|
+
if (queryTokens.length === 0) {
|
|
192
|
+
return typeWeight * Math.max(0.1, record.confidence ?? 1);
|
|
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;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Legacy scope names ('user'|'project'|'session') map onto v2 scope/type:
|
|
213
|
+
// 'project' ↔ scope 'repo'/'task', 'session' ↔ type 'episode'.
|
|
214
|
+
const LEGACY_SCOPE_MATCH = {
|
|
215
|
+
user: (record) => record.scope === 'user',
|
|
216
|
+
project: (record) => record.scope === 'repo' || record.scope === 'task',
|
|
217
|
+
session: (record) => record.type === 'episode' || record.scope === 'session',
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
function recordWithinScope(record, scope, projectId) {
|
|
221
|
+
if (scope !== 'all') {
|
|
222
|
+
const matcher = LEGACY_SCOPE_MATCH[scope];
|
|
223
|
+
const matches = matcher ? matcher(record) : (record.scope === scope || record.type === scope);
|
|
224
|
+
if (!matches) return false;
|
|
225
|
+
}
|
|
226
|
+
if (projectId && record.project_id && record.project_id !== projectId) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildRecordSearchResult(record, relevanceScore) {
|
|
233
|
+
return {
|
|
234
|
+
id: record.id,
|
|
235
|
+
summary: record.text,
|
|
236
|
+
relevanceScore,
|
|
237
|
+
source: record.type,
|
|
238
|
+
timestamp: record.created_at ?? 0,
|
|
239
|
+
sourcePath: null,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function searchRecords(records, query, scope, limit, projectId) {
|
|
244
|
+
const queryTokens = tokenize(query);
|
|
245
|
+
return records
|
|
246
|
+
.filter((record) => isRecordUsable(record))
|
|
247
|
+
.filter((record) => recordWithinScope(record, scope, projectId))
|
|
248
|
+
.map((record) => ({ record, score: scoreRecord(record, queryTokens) }))
|
|
249
|
+
.filter((entry) => entry.score > 0)
|
|
250
|
+
.sort((left, right) => (
|
|
251
|
+
right.score - left.score
|
|
252
|
+
|| (right.record.created_at ?? 0) - (left.record.created_at ?? 0)
|
|
253
|
+
|| String(right.record.text ?? '').localeCompare(String(left.record.text ?? ''))
|
|
254
|
+
))
|
|
255
|
+
.slice(0, limit)
|
|
256
|
+
.map((entry) => buildRecordSearchResult(entry.record, entry.score));
|
|
257
|
+
}
|
|
258
|
+
|
|
161
259
|
export async function search(query, scope = 'all', limit = 10, { projectRoot, projectId } = {}) {
|
|
260
|
+
const records = await queryV2Records(projectRoot);
|
|
261
|
+
if (records.length > 0) {
|
|
262
|
+
return searchRecords(records, query, scope, limit, projectId);
|
|
263
|
+
}
|
|
264
|
+
|
|
162
265
|
const items = await listMemoryItems(projectRoot);
|
|
163
266
|
const queryTokens = tokenize(query);
|
|
164
267
|
|
|
@@ -207,6 +310,33 @@ function findRelatedItemIds(targetItem, allItems) {
|
|
|
207
310
|
}
|
|
208
311
|
|
|
209
312
|
export async function expand(id, { projectRoot } = {}) {
|
|
313
|
+
const records = await queryV2Records(projectRoot);
|
|
314
|
+
if (records.length > 0) {
|
|
315
|
+
// Resolve legacy item ids too: 'session:<legacyId>' → episode with
|
|
316
|
+
// meta.legacyId, 'project:<projectId>' → records of that project.
|
|
317
|
+
const colonIdx = String(id).indexOf(':');
|
|
318
|
+
const legacyKind = colonIdx > 0 ? String(id).slice(0, colonIdx) : null;
|
|
319
|
+
const legacyId = colonIdx > 0 ? String(id).slice(colonIdx + 1) : id;
|
|
320
|
+
const target = records.find((record) => record.id === id)
|
|
321
|
+
?? (legacyKind === 'session'
|
|
322
|
+
? records.find((record) => record.type === 'episode' && record.meta?.legacyId === legacyId)
|
|
323
|
+
: legacyKind === 'project'
|
|
324
|
+
? records.find((record) => record.project_id === legacyId)
|
|
325
|
+
: null);
|
|
326
|
+
if (!target) {
|
|
327
|
+
return { id, fullContent: '', relatedItems: [] };
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
id: target.id,
|
|
331
|
+
fullContent: JSON.stringify(target, null, 2),
|
|
332
|
+
relatedItems: records
|
|
333
|
+
.filter((record) => record.id !== target.id)
|
|
334
|
+
.filter((record) => record.type === target.type || record.project_id === target.project_id)
|
|
335
|
+
.map((record) => record.id)
|
|
336
|
+
.slice(0, 5),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
210
340
|
const items = await listMemoryItems(projectRoot);
|
|
211
341
|
const target = items.find((item) => item.id === id);
|
|
212
342
|
if (!target) {
|
|
@@ -259,6 +389,15 @@ function buildInjectionSnippet(item) {
|
|
|
259
389
|
return `user-rules=${(content.rules ?? []).slice(0, 3).join('; ') || 'n/a'}`;
|
|
260
390
|
}
|
|
261
391
|
|
|
392
|
+
function includeUserMemoryOrRecord(includeUserMemory, record) {
|
|
393
|
+
return includeUserMemory || record.scope !== 'user';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function buildRecordSnippet(record) {
|
|
397
|
+
const text = String(record.text ?? '').trim();
|
|
398
|
+
return `${record.type}=${text.length > 160 ? `${text.slice(0, 157)}...` : text}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
262
401
|
function normalizePressurePhase(phase) {
|
|
263
402
|
const normalized = String(phase ?? '').trim().toLowerCase();
|
|
264
403
|
return ['monitor', 'soft', 'hard'].includes(normalized) ? normalized : 'monitor';
|
|
@@ -380,7 +519,8 @@ export async function getContextInjection(
|
|
|
380
519
|
inputCompression = true,
|
|
381
520
|
} = {},
|
|
382
521
|
) {
|
|
383
|
-
const
|
|
522
|
+
const records = await queryV2Records(projectRoot);
|
|
523
|
+
const items = records.length > 0 ? [] : await listMemoryItems(projectRoot);
|
|
384
524
|
const pressureState = await readCompactPressureState(projectRoot);
|
|
385
525
|
const recallPlan = resolveRecallPressurePlan({
|
|
386
526
|
maxTokens,
|
|
@@ -388,14 +528,20 @@ export async function getContextInjection(
|
|
|
388
528
|
pressureState,
|
|
389
529
|
});
|
|
390
530
|
const rankedItems = await search(currentTask, 'all', Math.max(limit * 3, 8), { projectRoot, projectId });
|
|
391
|
-
const selectedItems =
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
531
|
+
const selectedItems = records.length > 0
|
|
532
|
+
? dedupeItemsById(rankedItems
|
|
533
|
+
.map((entry) => records.find((record) => record.id === entry.id))
|
|
534
|
+
.filter(Boolean)
|
|
535
|
+
.filter((record) => includeUserMemoryOrRecord(recallPlan.includeUserMemory, record)))
|
|
536
|
+
.slice(0, Math.max(1, recallPlan.limit))
|
|
537
|
+
: selectContextItems({
|
|
538
|
+
items,
|
|
539
|
+
rankedItems,
|
|
540
|
+
projectId,
|
|
541
|
+
limit: recallPlan.limit,
|
|
542
|
+
includeUserMemory: recallPlan.includeUserMemory,
|
|
543
|
+
allowProjectFallback: !String(currentTask ?? '').trim(),
|
|
544
|
+
});
|
|
399
545
|
|
|
400
546
|
if (selectedItems.length === 0) {
|
|
401
547
|
return '';
|
|
@@ -418,8 +564,12 @@ export async function getContextInjection(
|
|
|
418
564
|
}
|
|
419
565
|
}
|
|
420
566
|
|
|
421
|
-
const rawBodyLines =
|
|
422
|
-
|
|
567
|
+
const rawBodyLines = records.length > 0
|
|
568
|
+
? selectedItems.map((record) => `- [${record.type}] ${buildRecordSnippet(record)}`)
|
|
569
|
+
: selectedItems.map((item) => `- [${item.type}] ${buildDetailedInjectionSnippet(item)}`);
|
|
570
|
+
const bodyLines = records.length > 0
|
|
571
|
+
? selectedItems.map((record) => `- ${buildRecordSnippet(record)}`)
|
|
572
|
+
: selectedItems.map((item) => `- ${buildInjectionSnippet(item)}`);
|
|
423
573
|
const rawLines = ['## Previous Context', ...rawBodyLines];
|
|
424
574
|
const rawText = rawLines.join('\n');
|
|
425
575
|
const conciseText = ['## Previous Context', ...bodyLines].join('\n');
|
package/src/core/memory/store.js
CHANGED
|
@@ -6,6 +6,178 @@ import { readJsonIfExists, writeJson } from '../fileOps.js';
|
|
|
6
6
|
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
7
7
|
import { runHygiene } from './hygiene.js';
|
|
8
8
|
|
|
9
|
+
// ---- Memory v2 facade (SPEC §3/§11) ----
|
|
10
|
+
// Every exported signature and CLI output shape is preserved. When the v2
|
|
11
|
+
// store is active — `v2/records.json` exists, or lazy `ensureMigrated` inside
|
|
12
|
+
// storeV2's read path just ran the v1→v2 migration — reads/writes delegate to
|
|
13
|
+
// storeV2 records. Otherwise the legacy file-based path below runs unchanged.
|
|
14
|
+
// storeV2/migrate are lazy-imported to keep this module decoupled and to match
|
|
15
|
+
// the storeV2↔migrate cycle-avoidance pattern.
|
|
16
|
+
|
|
17
|
+
async function storeV2() {
|
|
18
|
+
return import('./storeV2.js');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function v2Active(projectRoot) {
|
|
22
|
+
try {
|
|
23
|
+
const config = await loadRuntimeConfig(projectRoot);
|
|
24
|
+
if (config?.memoryV2?.enabled === false) return false;
|
|
25
|
+
} catch {
|
|
26
|
+
// unreadable config → default enabled, keep going
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const v2 = await storeV2();
|
|
30
|
+
// loadRecords triggers ensureMigrated (lazy migration) when autoMigrate is
|
|
31
|
+
// on; records.json existing afterwards — or already — means v2 mode.
|
|
32
|
+
await v2.loadRecords(projectRoot);
|
|
33
|
+
try {
|
|
34
|
+
await fs.access(buildRuntimePaths(projectRoot).memoryV2RecordsPath);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const LEGACY_CONVENTION_PROVENANCE = new Set([
|
|
42
|
+
'legacy:conventions',
|
|
43
|
+
'propose-approved',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function parsePreferenceText(text) {
|
|
47
|
+
const raw = String(text ?? '');
|
|
48
|
+
const idx = raw.indexOf(' = ');
|
|
49
|
+
if (idx <= 0) return null;
|
|
50
|
+
const key = raw.slice(0, idx).trim();
|
|
51
|
+
const valueRaw = raw.slice(idx + 3);
|
|
52
|
+
let value = valueRaw;
|
|
53
|
+
try {
|
|
54
|
+
value = JSON.parse(valueRaw);
|
|
55
|
+
} catch {
|
|
56
|
+
// keep string form
|
|
57
|
+
}
|
|
58
|
+
return [key, value];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function recordDecisionParts(text) {
|
|
62
|
+
const raw = String(text ?? '');
|
|
63
|
+
const idx = raw.indexOf(' — ');
|
|
64
|
+
if (idx <= 0) return { what: raw };
|
|
65
|
+
return { what: raw.slice(0, idx), why: raw.slice(idx + 3) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function summarizeSessionText(text) {
|
|
69
|
+
const raw = String(text ?? '');
|
|
70
|
+
const idx = raw.indexOf(' — ');
|
|
71
|
+
const task = idx > 0 ? raw.slice(0, idx) : raw;
|
|
72
|
+
const outcomeMatch = /outcome: ([^—]+)/.exec(raw);
|
|
73
|
+
const outcome = outcomeMatch ? outcomeMatch[1].trim() : 'unknown';
|
|
74
|
+
return `${task} — ${outcome}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function recordsToMemoryItems(records, runtimePaths) {
|
|
78
|
+
// list/export keep legacy semantics — all non-archived records stay visible
|
|
79
|
+
// (TTL-expired episodes included; recall applies isRecordUsable on top).
|
|
80
|
+
const usable = records.filter((record) => record.status !== 'archived');
|
|
81
|
+
|
|
82
|
+
// ---- user item (always emitted, like the legacy path) ----
|
|
83
|
+
const preferences = {};
|
|
84
|
+
const rules = [];
|
|
85
|
+
for (const record of usable.filter((r) => r.scope === 'user')) {
|
|
86
|
+
if (record.type === 'project_rule') {
|
|
87
|
+
rules.push(record.text);
|
|
88
|
+
} else {
|
|
89
|
+
const parsed = parsePreferenceText(record.text);
|
|
90
|
+
if (parsed) preferences[parsed[0]] = parsed[1];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const userMemory = { preferences, rules, updatedAt: Date.now() };
|
|
94
|
+
|
|
95
|
+
// ---- project items grouped by project_id ----
|
|
96
|
+
const projectIds = [...new Set(
|
|
97
|
+
usable
|
|
98
|
+
.filter((r) => typeof r.project_id === 'string' && r.project_id)
|
|
99
|
+
.map((r) => r.project_id),
|
|
100
|
+
)];
|
|
101
|
+
const projectItems = projectIds.map((projectId) => {
|
|
102
|
+
const scoped = usable.filter((r) => r.project_id === projectId && r.type !== 'episode');
|
|
103
|
+
const conventions = scoped
|
|
104
|
+
.filter((r) => r.type === 'project_rule'
|
|
105
|
+
&& (LEGACY_CONVENTION_PROVENANCE.has(r.provenance)
|
|
106
|
+
|| r.meta?.legacyStatus === 'approved'))
|
|
107
|
+
.map((r) => r.text);
|
|
108
|
+
const activeRules = scoped
|
|
109
|
+
.filter((r) => r.type === 'project_rule' && !conventions.includes(r.text))
|
|
110
|
+
.map((r) => r.text);
|
|
111
|
+
const decisions = scoped
|
|
112
|
+
.filter((r) => r.type === 'derived_fact' && r.provenance === 'legacy:decisions')
|
|
113
|
+
.map((r) => ({ id: r.meta?.id ?? r.id, ...recordDecisionParts(r.text), when: r.created_at }));
|
|
114
|
+
const profileFacts = scoped
|
|
115
|
+
.filter((r) => r.type === 'derived_fact' && r.provenance === 'legacy:project-profile')
|
|
116
|
+
.map((r) => r.text);
|
|
117
|
+
const content = {
|
|
118
|
+
id: projectId,
|
|
119
|
+
name: projectId,
|
|
120
|
+
architecture: profileFacts[0] ?? null,
|
|
121
|
+
techStack: profileFacts.slice(1),
|
|
122
|
+
conventions,
|
|
123
|
+
activeRules,
|
|
124
|
+
decisions,
|
|
125
|
+
patternCandidates: [],
|
|
126
|
+
sessions: [],
|
|
127
|
+
updatedAt: usable
|
|
128
|
+
.filter((r) => r.project_id === projectId)
|
|
129
|
+
.reduce((max, r) => Math.max(max, r.created_at ?? 0), 0),
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
id: `project:${projectId}`,
|
|
133
|
+
type: 'project',
|
|
134
|
+
summary: summarizeProjectMemory(content),
|
|
135
|
+
searchableText: createSearchableText('project', content),
|
|
136
|
+
sourcePath: path.join(runtimePaths.projectsDir, `${sanitizeProjectId(projectId)}.json`),
|
|
137
|
+
content,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ---- session items from episode records ----
|
|
142
|
+
const sessionItems = usable
|
|
143
|
+
.filter((r) => r.type === 'episode')
|
|
144
|
+
.map((record) => {
|
|
145
|
+
const legacyId = record.meta?.legacyId ?? record.id;
|
|
146
|
+
const content = {
|
|
147
|
+
id: legacyId,
|
|
148
|
+
projectId: record.project_id ?? null,
|
|
149
|
+
taskDescription: String(record.text ?? '').split(' — ')[0],
|
|
150
|
+
outcome: summarizeSessionText(record.text).split(' — ').pop(),
|
|
151
|
+
keyActions: [],
|
|
152
|
+
nextSteps: [],
|
|
153
|
+
filesChanged: [],
|
|
154
|
+
startedAt: record.created_at ?? null,
|
|
155
|
+
endedAt: record.created_at ?? null,
|
|
156
|
+
};
|
|
157
|
+
return {
|
|
158
|
+
id: `session:${legacyId}`,
|
|
159
|
+
type: 'session',
|
|
160
|
+
summary: summarizeSessionMemory(content),
|
|
161
|
+
searchableText: createSearchableText('session', content),
|
|
162
|
+
sourcePath: path.join(runtimePaths.sessionsDir, `${legacyId}.json`),
|
|
163
|
+
content,
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return [
|
|
168
|
+
{
|
|
169
|
+
id: 'user:user',
|
|
170
|
+
type: 'user',
|
|
171
|
+
summary: summarizeUserMemory(userMemory),
|
|
172
|
+
searchableText: createSearchableText('user', userMemory),
|
|
173
|
+
sourcePath: runtimePaths.userMemoryPath,
|
|
174
|
+
content: userMemory,
|
|
175
|
+
},
|
|
176
|
+
...projectItems,
|
|
177
|
+
...sessionItems,
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
|
|
9
181
|
function defaultUserMemory() {
|
|
10
182
|
return {
|
|
11
183
|
preferences: {},
|
|
@@ -100,6 +272,17 @@ function createSearchableText(type, content) {
|
|
|
100
272
|
|
|
101
273
|
export async function exportMemory(projectRoot) {
|
|
102
274
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
275
|
+
if (await v2Active(projectRoot)) {
|
|
276
|
+
const v2 = await storeV2();
|
|
277
|
+
const items = recordsToMemoryItems(await v2.loadRecords(projectRoot), runtimePaths);
|
|
278
|
+
const user = items.find((item) => item.type === 'user')?.content ?? defaultUserMemory();
|
|
279
|
+
return {
|
|
280
|
+
user,
|
|
281
|
+
projects: items.filter((item) => item.type === 'project').map((item) => item.content),
|
|
282
|
+
sessions: items.filter((item) => item.type === 'session').map((item) => item.content),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
103
286
|
const user = (await readMemoryJson(runtimePaths.userMemoryPath)) ?? defaultUserMemory();
|
|
104
287
|
const projects = (await readDirectoryJsonItems(runtimePaths.projectsDir)).map((item) => item.content);
|
|
105
288
|
const sessions = (await readDirectoryJsonItems(runtimePaths.sessionsDir)).map((item) => item.content);
|
|
@@ -107,8 +290,34 @@ export async function exportMemory(projectRoot) {
|
|
|
107
290
|
return { user, projects, sessions };
|
|
108
291
|
}
|
|
109
292
|
|
|
293
|
+
// Read-compat: corrupt legacy files still surface the same warning the legacy
|
|
294
|
+
// read path emits, so `ukit memory list` output stays identical post-migration.
|
|
295
|
+
async function warnOnCorruptLegacyFiles(runtimePaths) {
|
|
296
|
+
await readMemoryJson(runtimePaths.userMemoryPath);
|
|
297
|
+
for (const dirPath of [runtimePaths.projectsDir, runtimePaths.sessionsDir]) {
|
|
298
|
+
let entries = [];
|
|
299
|
+
try {
|
|
300
|
+
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
301
|
+
} catch {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
for (const entry of entries) {
|
|
305
|
+
if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
306
|
+
await readMemoryJson(path.join(dirPath, entry.name));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
110
312
|
export async function listMemoryItems(projectRoot) {
|
|
111
313
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
314
|
+
if (await v2Active(projectRoot)) {
|
|
315
|
+
const v2 = await storeV2();
|
|
316
|
+
const records = await v2.loadRecords(projectRoot);
|
|
317
|
+
await warnOnCorruptLegacyFiles(runtimePaths);
|
|
318
|
+
return recordsToMemoryItems(records, runtimePaths);
|
|
319
|
+
}
|
|
320
|
+
|
|
112
321
|
const userMemory = (await readMemoryJson(runtimePaths.userMemoryPath)) ?? defaultUserMemory();
|
|
113
322
|
const projectMemories = await readDirectoryJsonItems(runtimePaths.projectsDir);
|
|
114
323
|
const sessionMemories = await readDirectoryJsonItems(runtimePaths.sessionsDir);
|
|
@@ -141,8 +350,70 @@ export async function listMemoryItems(projectRoot) {
|
|
|
141
350
|
];
|
|
142
351
|
}
|
|
143
352
|
|
|
353
|
+
async function forgetMemoryItemV2(projectRoot, memoryId, runtimePaths) {
|
|
354
|
+
const v2 = await storeV2();
|
|
355
|
+
const archiveAll = async (records) => {
|
|
356
|
+
let archived = 0;
|
|
357
|
+
for (const record of records) {
|
|
358
|
+
if (record.status !== 'archived') {
|
|
359
|
+
await v2.updateRecord(projectRoot, record.id, { status: 'archived' });
|
|
360
|
+
archived += 1;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return archived;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
if (memoryId === 'user:user' || memoryId === 'user') {
|
|
367
|
+
const userRecords = await v2.queryRecords(projectRoot, { scope: 'user' });
|
|
368
|
+
const archived = await archiveAll(userRecords);
|
|
369
|
+
await writeJson(runtimePaths.userMemoryPath, defaultUserMemory());
|
|
370
|
+
return { removed: archived > 0 || true, type: 'user', path: runtimePaths.userMemoryPath };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const id = String(memoryId);
|
|
374
|
+
const separatorIndex = id.indexOf(':');
|
|
375
|
+
if (separatorIndex <= 0 || separatorIndex === id.length - 1) {
|
|
376
|
+
return { removed: false, type: null, path: null };
|
|
377
|
+
}
|
|
378
|
+
const type = id.slice(0, separatorIndex);
|
|
379
|
+
const rawId = id.slice(separatorIndex + 1);
|
|
380
|
+
if (rawId.includes('/') || rawId.includes('\\') || rawId === '..') {
|
|
381
|
+
return { removed: false, type, path: null };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const records = await v2.loadRecords(projectRoot);
|
|
385
|
+
const matched = type === 'session'
|
|
386
|
+
? records.filter((r) => r.type === 'episode' && (r.meta?.legacyId === rawId || r.id === rawId))
|
|
387
|
+
: type === 'project'
|
|
388
|
+
? records.filter((r) => r.project_id === rawId)
|
|
389
|
+
: [];
|
|
390
|
+
const archived = await archiveAll(matched);
|
|
391
|
+
|
|
392
|
+
// Keep the legacy mirror in sync so a later read-compat consumer cannot
|
|
393
|
+
// resurrect a forgotten item.
|
|
394
|
+
const baseDir = type === 'project'
|
|
395
|
+
? runtimePaths.projectsDir
|
|
396
|
+
: type === 'session'
|
|
397
|
+
? runtimePaths.sessionsDir
|
|
398
|
+
: null;
|
|
399
|
+
let legacyRemoved = false;
|
|
400
|
+
if (baseDir) {
|
|
401
|
+
try {
|
|
402
|
+
await fs.unlink(path.join(baseDir, `${rawId}.json`));
|
|
403
|
+
legacyRemoved = true;
|
|
404
|
+
} catch {
|
|
405
|
+
legacyRemoved = false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return { removed: archived > 0 || legacyRemoved, type: baseDir ? type : null, path: null };
|
|
410
|
+
}
|
|
411
|
+
|
|
144
412
|
export async function forgetMemoryItem(projectRoot, memoryId) {
|
|
145
413
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
414
|
+
if (await v2Active(projectRoot)) {
|
|
415
|
+
return forgetMemoryItemV2(projectRoot, memoryId, runtimePaths);
|
|
416
|
+
}
|
|
146
417
|
if (memoryId === 'user:user' || memoryId === 'user') {
|
|
147
418
|
await writeJson(runtimePaths.userMemoryPath, defaultUserMemory());
|
|
148
419
|
return { removed: true, type: 'user', path: runtimePaths.userMemoryPath };
|
|
@@ -246,8 +517,106 @@ export async function runProjectHygiene(projectRoot, projectId) {
|
|
|
246
517
|
return persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
|
|
247
518
|
}
|
|
248
519
|
|
|
520
|
+
function patternCandidateFromRecord(record) {
|
|
521
|
+
return {
|
|
522
|
+
id: record.meta?.id ?? record.id,
|
|
523
|
+
text: record.text,
|
|
524
|
+
category: record.meta?.category ?? null,
|
|
525
|
+
detectedFrom: record.meta?.detectedFrom ?? null,
|
|
526
|
+
detectedAt: record.meta?.detectedAt ?? record.created_at ?? null,
|
|
527
|
+
status: record.meta?.legacyStatus ?? 'pending',
|
|
528
|
+
_recordId: record.id,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function proposePatternCandidateV2(projectRoot, projectId, candidate) {
|
|
533
|
+
const v2 = await storeV2();
|
|
534
|
+
const normalizedText = normalizePatternText(candidate?.text);
|
|
535
|
+
if (!normalizedText) {
|
|
536
|
+
throw new Error('proposePatternCandidate requires candidate.text');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const existing = (await v2.queryRecords(projectRoot, { projectId }))
|
|
540
|
+
.filter((record) => record.provenance === 'pattern-candidate'
|
|
541
|
+
|| String(record.provenance ?? '').includes('pattern-candidate'))
|
|
542
|
+
.find((record) => record.meta?.legacyStatus === 'pending'
|
|
543
|
+
&& normalizePatternText(record.text) === normalizedText);
|
|
544
|
+
if (existing) {
|
|
545
|
+
return patternCandidateFromRecord(existing);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const entry = {
|
|
549
|
+
id: `pc_${crypto.randomBytes(6).toString('hex')}`,
|
|
550
|
+
text: candidate.text,
|
|
551
|
+
category: candidate?.category ?? null,
|
|
552
|
+
detectedFrom: candidate?.detectedFrom ?? null,
|
|
553
|
+
detectedAt: Date.now(),
|
|
554
|
+
status: 'pending',
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
await v2.addRecord(projectRoot, {
|
|
558
|
+
type: 'derived_fact',
|
|
559
|
+
scope: 'task',
|
|
560
|
+
text: entry.text,
|
|
561
|
+
provenance: 'pattern-candidate',
|
|
562
|
+
confidence: 0.4,
|
|
563
|
+
createdBy: 'propose',
|
|
564
|
+
projectId,
|
|
565
|
+
meta: { ...entry, legacyStatus: 'pending' },
|
|
566
|
+
});
|
|
567
|
+
return entry;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function listPendingPatternCandidatesV2(projectRoot, projectId) {
|
|
571
|
+
const v2 = await storeV2();
|
|
572
|
+
return (await v2.queryRecords(projectRoot, { projectId }))
|
|
573
|
+
.filter((record) => record.type === 'derived_fact'
|
|
574
|
+
&& record.meta?.legacyStatus === 'pending'
|
|
575
|
+
&& record.status !== 'archived')
|
|
576
|
+
.map(patternCandidateFromRecord);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function resolvePatternCandidateV2(projectRoot, projectId, candidateId, decision) {
|
|
580
|
+
const v2 = await storeV2();
|
|
581
|
+
const records = await v2.queryRecords(projectRoot, { projectId });
|
|
582
|
+
const target = records.find((record) => record.meta?.id === candidateId || record.id === candidateId);
|
|
583
|
+
if (!target) {
|
|
584
|
+
throw new Error(`Pattern candidate not found: ${candidateId}`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
let updated;
|
|
588
|
+
if (decision === 'approve') {
|
|
589
|
+
updated = await v2.updateRecord(projectRoot, target.id, {
|
|
590
|
+
type: 'project_rule',
|
|
591
|
+
scope: 'repo',
|
|
592
|
+
confidence: 0.9,
|
|
593
|
+
provenance: `${target.provenance ?? 'pattern-candidate'};promoted-from:${target.id}`,
|
|
594
|
+
meta: { ...target.meta, legacyStatus: 'approved' },
|
|
595
|
+
});
|
|
596
|
+
} else {
|
|
597
|
+
updated = await v2.updateRecord(projectRoot, target.id, {
|
|
598
|
+
status: 'archived',
|
|
599
|
+
meta: { ...target.meta, legacyStatus: 'rejected' },
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
return patternCandidateFromRecord(updated ?? target);
|
|
603
|
+
}
|
|
604
|
+
|
|
249
605
|
export async function proposePatternCandidate(projectRoot, projectId, candidate) {
|
|
250
606
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
607
|
+
if (await v2Active(projectRoot)) {
|
|
608
|
+
const entry = await proposePatternCandidateV2(projectRoot, projectId, candidate);
|
|
609
|
+
// Mirror into the legacy project file (hygiene + conventions) so the
|
|
610
|
+
// untouched-on-disk legacy format stays read-compatible.
|
|
611
|
+
const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
|
|
612
|
+
const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
|
|
613
|
+
if (!patternCandidates.some((existing) => existing.id === entry.id)) {
|
|
614
|
+
memory.patternCandidates = [...patternCandidates, entry];
|
|
615
|
+
await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
|
|
616
|
+
}
|
|
617
|
+
return entry;
|
|
618
|
+
}
|
|
619
|
+
|
|
251
620
|
const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
|
|
252
621
|
|
|
253
622
|
const normalizedText = normalizePatternText(candidate?.text);
|
|
@@ -279,6 +648,10 @@ export async function proposePatternCandidate(projectRoot, projectId, candidate)
|
|
|
279
648
|
|
|
280
649
|
export async function listPendingPatternCandidates(projectRoot, projectId) {
|
|
281
650
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
651
|
+
if (await v2Active(projectRoot)) {
|
|
652
|
+
return listPendingPatternCandidatesV2(projectRoot, projectId);
|
|
653
|
+
}
|
|
654
|
+
|
|
282
655
|
const { memory } = await readProjectMemoryForId(runtimePaths, projectId);
|
|
283
656
|
const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
|
|
284
657
|
return patternCandidates.filter((entry) => entry.status === 'pending');
|
|
@@ -290,6 +663,31 @@ export async function resolvePatternCandidate(projectRoot, projectId, candidateI
|
|
|
290
663
|
}
|
|
291
664
|
|
|
292
665
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
666
|
+
if (await v2Active(projectRoot)) {
|
|
667
|
+
const resolved = await resolvePatternCandidateV2(projectRoot, projectId, candidateId, decision);
|
|
668
|
+
// Mirror the resolution into the legacy project file.
|
|
669
|
+
const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
|
|
670
|
+
const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
|
|
671
|
+
const target = patternCandidates.find((entry) => entry.id === candidateId);
|
|
672
|
+
if (target) {
|
|
673
|
+
target.status = resolved.status;
|
|
674
|
+
if (decision === 'approve') {
|
|
675
|
+
const conventions = Array.isArray(memory.conventions) ? memory.conventions : [];
|
|
676
|
+
if (!conventions.includes(target.text)) {
|
|
677
|
+
memory.conventions = [...conventions, target.text];
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
|
|
681
|
+
} else if (decision === 'approve') {
|
|
682
|
+
const conventions = Array.isArray(memory.conventions) ? memory.conventions : [];
|
|
683
|
+
if (!conventions.includes(resolved.text)) {
|
|
684
|
+
memory.conventions = [...conventions, resolved.text];
|
|
685
|
+
await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return resolved;
|
|
689
|
+
}
|
|
690
|
+
|
|
293
691
|
const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
|
|
294
692
|
|
|
295
693
|
const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
|