@fermindi/pwn-cli 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +265 -251
- package/cli/batch.js +333 -333
- package/cli/codespaces.js +303 -303
- package/cli/index.js +98 -91
- package/cli/inject.js +78 -67
- package/cli/knowledge.js +531 -531
- package/cli/migrate.js +466 -0
- package/cli/notify.js +135 -135
- package/cli/patterns.js +665 -665
- package/cli/status.js +91 -91
- package/cli/validate.js +61 -61
- package/package.json +70 -70
- package/src/core/inject.js +208 -204
- package/src/core/state.js +91 -91
- package/src/core/validate.js +202 -202
- package/src/core/workspace.js +176 -176
- package/src/index.js +20 -20
- package/src/knowledge/gc.js +308 -308
- package/src/knowledge/lifecycle.js +401 -401
- package/src/knowledge/promote.js +364 -364
- package/src/knowledge/references.js +342 -342
- package/src/patterns/matcher.js +218 -218
- package/src/patterns/registry.js +375 -375
- package/src/patterns/triggers.js +423 -423
- package/src/services/batch-service.js +849 -849
- package/src/services/notification-service.js +342 -342
- package/templates/codespaces/devcontainer.json +52 -52
- package/templates/codespaces/setup.sh +70 -70
- package/templates/workspace/.ai/README.md +164 -164
- package/templates/workspace/.ai/agents/README.md +204 -204
- package/templates/workspace/.ai/agents/claude.md +625 -625
- package/templates/workspace/.ai/config/README.md +79 -79
- package/templates/workspace/.ai/config/notifications.template.json +20 -20
- package/templates/workspace/.ai/memory/deadends.md +79 -79
- package/templates/workspace/.ai/memory/decisions.md +58 -58
- package/templates/workspace/.ai/memory/patterns.md +65 -65
- package/templates/workspace/.ai/patterns/backend/README.md +126 -126
- package/templates/workspace/.ai/patterns/frontend/README.md +103 -103
- package/templates/workspace/.ai/patterns/index.md +256 -256
- package/templates/workspace/.ai/patterns/triggers.json +1087 -1087
- package/templates/workspace/.ai/patterns/universal/README.md +141 -141
- package/templates/workspace/.ai/state.template.json +8 -8
- package/templates/workspace/.ai/tasks/active.md +77 -77
- package/templates/workspace/.ai/tasks/backlog.md +95 -95
- package/templates/workspace/.ai/workflows/batch-task.md +356 -356
package/src/knowledge/gc.js
CHANGED
|
@@ -1,308 +1,308 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
3
|
-
import {
|
|
4
|
-
getKnowledgeRegistry,
|
|
5
|
-
saveKnowledgeRegistry,
|
|
6
|
-
getAllKnowledge,
|
|
7
|
-
changeStatus
|
|
8
|
-
} from './lifecycle.js';
|
|
9
|
-
import { findBrokenReferences } from './references.js';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* GC Result
|
|
13
|
-
* @typedef {Object} GCResult
|
|
14
|
-
* @property {string[]} marked - Items marked for deletion
|
|
15
|
-
* @property {string[]} archived - Items archived
|
|
16
|
-
* @property {string[]} deleted - Items removed
|
|
17
|
-
* @property {string[]} cleaned - Broken references cleaned
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Identify items that should be garbage collected
|
|
22
|
-
* Criteria:
|
|
23
|
-
* - No related files exist
|
|
24
|
-
* - No references from other decisions
|
|
25
|
-
* - Status is 'garbage' or orphaned
|
|
26
|
-
* @param {string} cwd - Working directory
|
|
27
|
-
* @returns {Object[]} Items to collect
|
|
28
|
-
*/
|
|
29
|
-
export function identifyGarbage(cwd = process.cwd()) {
|
|
30
|
-
const registry = getKnowledgeRegistry(cwd);
|
|
31
|
-
if (!registry) {
|
|
32
|
-
return [];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const garbage = [];
|
|
36
|
-
|
|
37
|
-
for (const [id, item] of Object.entries(registry.items)) {
|
|
38
|
-
// Already marked as garbage
|
|
39
|
-
if (item.status === 'garbage') {
|
|
40
|
-
garbage.push({
|
|
41
|
-
id,
|
|
42
|
-
title: item.title,
|
|
43
|
-
reason: 'marked_garbage',
|
|
44
|
-
relatedFiles: item.relatedFiles || [],
|
|
45
|
-
relatedDecisions: item.relatedDecisions || []
|
|
46
|
-
});
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Check if orphaned (no references)
|
|
51
|
-
const hasFileRefs = (item.relatedFiles || []).length > 0;
|
|
52
|
-
const hasDecisionRefs = (item.relatedDecisions || []).length > 0;
|
|
53
|
-
const hasTaskRefs = (item.relatedTasks || []).length > 0;
|
|
54
|
-
|
|
55
|
-
if (!hasFileRefs && !hasDecisionRefs && !hasTaskRefs) {
|
|
56
|
-
// Check if it's been accessed recently (last 30 days)
|
|
57
|
-
const lastAccessed = item.lastAccessed ? new Date(item.lastAccessed) : null;
|
|
58
|
-
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
59
|
-
|
|
60
|
-
if (!lastAccessed || lastAccessed < thirtyDaysAgo) {
|
|
61
|
-
garbage.push({
|
|
62
|
-
id,
|
|
63
|
-
title: item.title,
|
|
64
|
-
reason: 'orphaned',
|
|
65
|
-
lastAccessed: item.lastAccessed,
|
|
66
|
-
relatedFiles: [],
|
|
67
|
-
relatedDecisions: []
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return garbage;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Run garbage collection in dry-run mode
|
|
78
|
-
* @param {string} cwd - Working directory
|
|
79
|
-
* @returns {GCResult}
|
|
80
|
-
*/
|
|
81
|
-
export function dryRunGC(cwd = process.cwd()) {
|
|
82
|
-
const garbage = identifyGarbage(cwd);
|
|
83
|
-
const broken = findBrokenReferences(cwd);
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
marked: garbage.map(g => g.id),
|
|
87
|
-
archived: [],
|
|
88
|
-
deleted: [],
|
|
89
|
-
cleaned: broken.map(b => b.id),
|
|
90
|
-
details: {
|
|
91
|
-
garbage,
|
|
92
|
-
broken
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Execute garbage collection
|
|
99
|
-
* @param {Object} options - Options
|
|
100
|
-
* @param {boolean} options.archive - Archive instead of delete
|
|
101
|
-
* @param {boolean} options.cleanBroken - Clean broken references
|
|
102
|
-
* @param {string} cwd - Working directory
|
|
103
|
-
* @returns {GCResult}
|
|
104
|
-
*/
|
|
105
|
-
export function executeGC(options = {}, cwd = process.cwd()) {
|
|
106
|
-
const { archive = true, cleanBroken = true } = options;
|
|
107
|
-
const registry = getKnowledgeRegistry(cwd);
|
|
108
|
-
|
|
109
|
-
if (!registry) {
|
|
110
|
-
return { marked: [], archived: [], deleted: [], cleaned: [] };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const result = {
|
|
114
|
-
marked: [],
|
|
115
|
-
archived: [],
|
|
116
|
-
deleted: [],
|
|
117
|
-
cleaned: []
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
// Identify garbage
|
|
121
|
-
const garbage = identifyGarbage(cwd);
|
|
122
|
-
|
|
123
|
-
for (const item of garbage) {
|
|
124
|
-
if (archive) {
|
|
125
|
-
// Archive to memory/archive
|
|
126
|
-
archiveKnowledgeItem(item.id, cwd);
|
|
127
|
-
// Update local registry reference to reflect archived status
|
|
128
|
-
registry.items[item.id].status = 'archived';
|
|
129
|
-
result.archived.push(item.id);
|
|
130
|
-
} else {
|
|
131
|
-
// Delete from registry
|
|
132
|
-
delete registry.items[item.id];
|
|
133
|
-
result.deleted.push(item.id);
|
|
134
|
-
}
|
|
135
|
-
result.marked.push(item.id);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Clean broken references if requested
|
|
139
|
-
if (cleanBroken) {
|
|
140
|
-
const broken = findBrokenReferences(cwd);
|
|
141
|
-
for (const { id, brokenFiles, brokenDecisions } of broken) {
|
|
142
|
-
const item = registry.items[id];
|
|
143
|
-
if (item) {
|
|
144
|
-
// Remove broken file references
|
|
145
|
-
item.relatedFiles = (item.relatedFiles || []).filter(
|
|
146
|
-
f => !brokenFiles.includes(f)
|
|
147
|
-
);
|
|
148
|
-
// Remove broken decision references
|
|
149
|
-
item.relatedDecisions = (item.relatedDecisions || []).filter(
|
|
150
|
-
d => !brokenDecisions.includes(d)
|
|
151
|
-
);
|
|
152
|
-
result.cleaned.push(id);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Update last GC timestamp
|
|
158
|
-
registry.lastGC = new Date().toISOString();
|
|
159
|
-
saveKnowledgeRegistry(registry, cwd);
|
|
160
|
-
|
|
161
|
-
return result;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Archive a knowledge item to memory/archive
|
|
166
|
-
* @param {string} id - Item ID
|
|
167
|
-
* @param {string} cwd - Working directory
|
|
168
|
-
*/
|
|
169
|
-
function archiveKnowledgeItem(id, cwd = process.cwd()) {
|
|
170
|
-
const registry = getKnowledgeRegistry(cwd);
|
|
171
|
-
if (!registry || !registry.items[id]) return;
|
|
172
|
-
|
|
173
|
-
const item = registry.items[id];
|
|
174
|
-
const archiveDir = join(cwd, '.ai', 'memory', 'archive');
|
|
175
|
-
|
|
176
|
-
// Ensure archive directory exists
|
|
177
|
-
if (!existsSync(archiveDir)) {
|
|
178
|
-
mkdirSync(archiveDir, { recursive: true });
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Append to archive file
|
|
182
|
-
const archiveFile = join(archiveDir, 'archived-knowledge.md');
|
|
183
|
-
const archiveEntry = formatArchiveEntry(item);
|
|
184
|
-
|
|
185
|
-
appendFileSync(archiveFile, archiveEntry);
|
|
186
|
-
|
|
187
|
-
// Update status in registry
|
|
188
|
-
registry.items[id].status = 'archived';
|
|
189
|
-
registry.items[id].archivedAt = new Date().toISOString();
|
|
190
|
-
saveKnowledgeRegistry(registry, cwd);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Format a knowledge item for archive
|
|
195
|
-
* @param {Object} item - Knowledge item
|
|
196
|
-
* @returns {string}
|
|
197
|
-
*/
|
|
198
|
-
function formatArchiveEntry(item) {
|
|
199
|
-
const divider = '\n---\n\n';
|
|
200
|
-
const header = `## ${item.id}: ${item.title} [ARCHIVED]\n`;
|
|
201
|
-
const metadata = [
|
|
202
|
-
`**Type:** ${item.type}`,
|
|
203
|
-
`**Created:** ${item.createdAt || 'Unknown'}`,
|
|
204
|
-
`**Archived:** ${new Date().toISOString().split('T')[0]}`,
|
|
205
|
-
`**Access Count:** ${item.accessCount || 0}`,
|
|
206
|
-
`**Applied Count:** ${item.appliedCount || 0}`,
|
|
207
|
-
`**Last Accessed:** ${item.lastAccessed || 'Never'}`
|
|
208
|
-
].join('\n');
|
|
209
|
-
|
|
210
|
-
const refs = [];
|
|
211
|
-
if (item.relatedFiles?.length) {
|
|
212
|
-
refs.push(`**Related Files:** ${item.relatedFiles.join(', ')}`);
|
|
213
|
-
}
|
|
214
|
-
if (item.relatedDecisions?.length) {
|
|
215
|
-
refs.push(`**Related Decisions:** ${item.relatedDecisions.join(', ')}`);
|
|
216
|
-
}
|
|
217
|
-
if (item.appliedContexts?.length) {
|
|
218
|
-
refs.push(`**Applied Contexts:** ${item.appliedContexts.join(', ')}`);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return header + metadata + '\n' + refs.join('\n') + divider;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Get items that are candidates for archival (inactive but not orphaned)
|
|
226
|
-
* @param {number} inactiveDays - Days of inactivity
|
|
227
|
-
* @param {string} cwd - Working directory
|
|
228
|
-
* @returns {Object[]}
|
|
229
|
-
*/
|
|
230
|
-
export function getArchivalCandidates(inactiveDays = 60, cwd = process.cwd()) {
|
|
231
|
-
const registry = getKnowledgeRegistry(cwd);
|
|
232
|
-
if (!registry) return [];
|
|
233
|
-
|
|
234
|
-
const candidates = [];
|
|
235
|
-
const cutoff = new Date(Date.now() - inactiveDays * 24 * 60 * 60 * 1000);
|
|
236
|
-
|
|
237
|
-
for (const [id, item] of Object.entries(registry.items)) {
|
|
238
|
-
if (item.status !== 'active') continue;
|
|
239
|
-
|
|
240
|
-
const lastAccessed = item.lastAccessed ? new Date(item.lastAccessed) : null;
|
|
241
|
-
|
|
242
|
-
// Has references but hasn't been accessed
|
|
243
|
-
const hasRefs = (item.relatedFiles || []).length > 0 ||
|
|
244
|
-
(item.relatedDecisions || []).length > 0;
|
|
245
|
-
|
|
246
|
-
if (hasRefs && (!lastAccessed || lastAccessed < cutoff)) {
|
|
247
|
-
candidates.push({
|
|
248
|
-
id,
|
|
249
|
-
title: item.title,
|
|
250
|
-
lastAccessed: item.lastAccessed,
|
|
251
|
-
daysSinceAccess: lastAccessed
|
|
252
|
-
? Math.floor((Date.now() - lastAccessed) / (24 * 60 * 60 * 1000))
|
|
253
|
-
: 'never',
|
|
254
|
-
refCount: (item.relatedFiles || []).length + (item.relatedDecisions || []).length
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return candidates.sort((a, b) => {
|
|
260
|
-
const aDate = a.lastAccessed ? new Date(a.lastAccessed) : new Date(0);
|
|
261
|
-
const bDate = b.lastAccessed ? new Date(b.lastAccessed) : new Date(0);
|
|
262
|
-
return aDate - bDate;
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Restore an archived item back to active
|
|
268
|
-
* @param {string} id - Item ID
|
|
269
|
-
* @param {string} cwd - Working directory
|
|
270
|
-
* @returns {Object|null}
|
|
271
|
-
*/
|
|
272
|
-
export function restoreFromArchive(id, cwd = process.cwd()) {
|
|
273
|
-
const registry = getKnowledgeRegistry(cwd);
|
|
274
|
-
if (!registry || !registry.items[id]) return null;
|
|
275
|
-
|
|
276
|
-
const item = registry.items[id];
|
|
277
|
-
if (item.status !== 'archived') {
|
|
278
|
-
return { error: `Item ${id} is not archived (status: ${item.status})` };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
item.status = 'active';
|
|
282
|
-
item.restoredAt = new Date().toISOString();
|
|
283
|
-
delete item.archivedAt;
|
|
284
|
-
|
|
285
|
-
saveKnowledgeRegistry(registry, cwd);
|
|
286
|
-
return item;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Get GC statistics
|
|
291
|
-
* @param {string} cwd - Working directory
|
|
292
|
-
* @returns {Object}
|
|
293
|
-
*/
|
|
294
|
-
export function getGCStats(cwd = process.cwd()) {
|
|
295
|
-
const registry = getKnowledgeRegistry(cwd);
|
|
296
|
-
const garbage = identifyGarbage(cwd);
|
|
297
|
-
const archivalCandidates = getArchivalCandidates(60, cwd);
|
|
298
|
-
const broken = findBrokenReferences(cwd);
|
|
299
|
-
|
|
300
|
-
return {
|
|
301
|
-
lastGC: registry?.lastGC || 'Never',
|
|
302
|
-
garbageCount: garbage.length,
|
|
303
|
-
archivalCandidates: archivalCandidates.length,
|
|
304
|
-
brokenReferences: broken.length,
|
|
305
|
-
garbage: garbage.map(g => ({ id: g.id, reason: g.reason })),
|
|
306
|
-
candidates: archivalCandidates.slice(0, 5)
|
|
307
|
-
};
|
|
308
|
-
}
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import {
|
|
4
|
+
getKnowledgeRegistry,
|
|
5
|
+
saveKnowledgeRegistry,
|
|
6
|
+
getAllKnowledge,
|
|
7
|
+
changeStatus
|
|
8
|
+
} from './lifecycle.js';
|
|
9
|
+
import { findBrokenReferences } from './references.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GC Result
|
|
13
|
+
* @typedef {Object} GCResult
|
|
14
|
+
* @property {string[]} marked - Items marked for deletion
|
|
15
|
+
* @property {string[]} archived - Items archived
|
|
16
|
+
* @property {string[]} deleted - Items removed
|
|
17
|
+
* @property {string[]} cleaned - Broken references cleaned
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Identify items that should be garbage collected
|
|
22
|
+
* Criteria:
|
|
23
|
+
* - No related files exist
|
|
24
|
+
* - No references from other decisions
|
|
25
|
+
* - Status is 'garbage' or orphaned
|
|
26
|
+
* @param {string} cwd - Working directory
|
|
27
|
+
* @returns {Object[]} Items to collect
|
|
28
|
+
*/
|
|
29
|
+
export function identifyGarbage(cwd = process.cwd()) {
|
|
30
|
+
const registry = getKnowledgeRegistry(cwd);
|
|
31
|
+
if (!registry) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const garbage = [];
|
|
36
|
+
|
|
37
|
+
for (const [id, item] of Object.entries(registry.items)) {
|
|
38
|
+
// Already marked as garbage
|
|
39
|
+
if (item.status === 'garbage') {
|
|
40
|
+
garbage.push({
|
|
41
|
+
id,
|
|
42
|
+
title: item.title,
|
|
43
|
+
reason: 'marked_garbage',
|
|
44
|
+
relatedFiles: item.relatedFiles || [],
|
|
45
|
+
relatedDecisions: item.relatedDecisions || []
|
|
46
|
+
});
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check if orphaned (no references)
|
|
51
|
+
const hasFileRefs = (item.relatedFiles || []).length > 0;
|
|
52
|
+
const hasDecisionRefs = (item.relatedDecisions || []).length > 0;
|
|
53
|
+
const hasTaskRefs = (item.relatedTasks || []).length > 0;
|
|
54
|
+
|
|
55
|
+
if (!hasFileRefs && !hasDecisionRefs && !hasTaskRefs) {
|
|
56
|
+
// Check if it's been accessed recently (last 30 days)
|
|
57
|
+
const lastAccessed = item.lastAccessed ? new Date(item.lastAccessed) : null;
|
|
58
|
+
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
59
|
+
|
|
60
|
+
if (!lastAccessed || lastAccessed < thirtyDaysAgo) {
|
|
61
|
+
garbage.push({
|
|
62
|
+
id,
|
|
63
|
+
title: item.title,
|
|
64
|
+
reason: 'orphaned',
|
|
65
|
+
lastAccessed: item.lastAccessed,
|
|
66
|
+
relatedFiles: [],
|
|
67
|
+
relatedDecisions: []
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return garbage;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Run garbage collection in dry-run mode
|
|
78
|
+
* @param {string} cwd - Working directory
|
|
79
|
+
* @returns {GCResult}
|
|
80
|
+
*/
|
|
81
|
+
export function dryRunGC(cwd = process.cwd()) {
|
|
82
|
+
const garbage = identifyGarbage(cwd);
|
|
83
|
+
const broken = findBrokenReferences(cwd);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
marked: garbage.map(g => g.id),
|
|
87
|
+
archived: [],
|
|
88
|
+
deleted: [],
|
|
89
|
+
cleaned: broken.map(b => b.id),
|
|
90
|
+
details: {
|
|
91
|
+
garbage,
|
|
92
|
+
broken
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Execute garbage collection
|
|
99
|
+
* @param {Object} options - Options
|
|
100
|
+
* @param {boolean} options.archive - Archive instead of delete
|
|
101
|
+
* @param {boolean} options.cleanBroken - Clean broken references
|
|
102
|
+
* @param {string} cwd - Working directory
|
|
103
|
+
* @returns {GCResult}
|
|
104
|
+
*/
|
|
105
|
+
export function executeGC(options = {}, cwd = process.cwd()) {
|
|
106
|
+
const { archive = true, cleanBroken = true } = options;
|
|
107
|
+
const registry = getKnowledgeRegistry(cwd);
|
|
108
|
+
|
|
109
|
+
if (!registry) {
|
|
110
|
+
return { marked: [], archived: [], deleted: [], cleaned: [] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const result = {
|
|
114
|
+
marked: [],
|
|
115
|
+
archived: [],
|
|
116
|
+
deleted: [],
|
|
117
|
+
cleaned: []
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Identify garbage
|
|
121
|
+
const garbage = identifyGarbage(cwd);
|
|
122
|
+
|
|
123
|
+
for (const item of garbage) {
|
|
124
|
+
if (archive) {
|
|
125
|
+
// Archive to memory/archive
|
|
126
|
+
archiveKnowledgeItem(item.id, cwd);
|
|
127
|
+
// Update local registry reference to reflect archived status
|
|
128
|
+
registry.items[item.id].status = 'archived';
|
|
129
|
+
result.archived.push(item.id);
|
|
130
|
+
} else {
|
|
131
|
+
// Delete from registry
|
|
132
|
+
delete registry.items[item.id];
|
|
133
|
+
result.deleted.push(item.id);
|
|
134
|
+
}
|
|
135
|
+
result.marked.push(item.id);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Clean broken references if requested
|
|
139
|
+
if (cleanBroken) {
|
|
140
|
+
const broken = findBrokenReferences(cwd);
|
|
141
|
+
for (const { id, brokenFiles, brokenDecisions } of broken) {
|
|
142
|
+
const item = registry.items[id];
|
|
143
|
+
if (item) {
|
|
144
|
+
// Remove broken file references
|
|
145
|
+
item.relatedFiles = (item.relatedFiles || []).filter(
|
|
146
|
+
f => !brokenFiles.includes(f)
|
|
147
|
+
);
|
|
148
|
+
// Remove broken decision references
|
|
149
|
+
item.relatedDecisions = (item.relatedDecisions || []).filter(
|
|
150
|
+
d => !brokenDecisions.includes(d)
|
|
151
|
+
);
|
|
152
|
+
result.cleaned.push(id);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Update last GC timestamp
|
|
158
|
+
registry.lastGC = new Date().toISOString();
|
|
159
|
+
saveKnowledgeRegistry(registry, cwd);
|
|
160
|
+
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Archive a knowledge item to memory/archive
|
|
166
|
+
* @param {string} id - Item ID
|
|
167
|
+
* @param {string} cwd - Working directory
|
|
168
|
+
*/
|
|
169
|
+
function archiveKnowledgeItem(id, cwd = process.cwd()) {
|
|
170
|
+
const registry = getKnowledgeRegistry(cwd);
|
|
171
|
+
if (!registry || !registry.items[id]) return;
|
|
172
|
+
|
|
173
|
+
const item = registry.items[id];
|
|
174
|
+
const archiveDir = join(cwd, '.ai', 'memory', 'archive');
|
|
175
|
+
|
|
176
|
+
// Ensure archive directory exists
|
|
177
|
+
if (!existsSync(archiveDir)) {
|
|
178
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Append to archive file
|
|
182
|
+
const archiveFile = join(archiveDir, 'archived-knowledge.md');
|
|
183
|
+
const archiveEntry = formatArchiveEntry(item);
|
|
184
|
+
|
|
185
|
+
appendFileSync(archiveFile, archiveEntry);
|
|
186
|
+
|
|
187
|
+
// Update status in registry
|
|
188
|
+
registry.items[id].status = 'archived';
|
|
189
|
+
registry.items[id].archivedAt = new Date().toISOString();
|
|
190
|
+
saveKnowledgeRegistry(registry, cwd);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Format a knowledge item for archive
|
|
195
|
+
* @param {Object} item - Knowledge item
|
|
196
|
+
* @returns {string}
|
|
197
|
+
*/
|
|
198
|
+
function formatArchiveEntry(item) {
|
|
199
|
+
const divider = '\n---\n\n';
|
|
200
|
+
const header = `## ${item.id}: ${item.title} [ARCHIVED]\n`;
|
|
201
|
+
const metadata = [
|
|
202
|
+
`**Type:** ${item.type}`,
|
|
203
|
+
`**Created:** ${item.createdAt || 'Unknown'}`,
|
|
204
|
+
`**Archived:** ${new Date().toISOString().split('T')[0]}`,
|
|
205
|
+
`**Access Count:** ${item.accessCount || 0}`,
|
|
206
|
+
`**Applied Count:** ${item.appliedCount || 0}`,
|
|
207
|
+
`**Last Accessed:** ${item.lastAccessed || 'Never'}`
|
|
208
|
+
].join('\n');
|
|
209
|
+
|
|
210
|
+
const refs = [];
|
|
211
|
+
if (item.relatedFiles?.length) {
|
|
212
|
+
refs.push(`**Related Files:** ${item.relatedFiles.join(', ')}`);
|
|
213
|
+
}
|
|
214
|
+
if (item.relatedDecisions?.length) {
|
|
215
|
+
refs.push(`**Related Decisions:** ${item.relatedDecisions.join(', ')}`);
|
|
216
|
+
}
|
|
217
|
+
if (item.appliedContexts?.length) {
|
|
218
|
+
refs.push(`**Applied Contexts:** ${item.appliedContexts.join(', ')}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return header + metadata + '\n' + refs.join('\n') + divider;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get items that are candidates for archival (inactive but not orphaned)
|
|
226
|
+
* @param {number} inactiveDays - Days of inactivity
|
|
227
|
+
* @param {string} cwd - Working directory
|
|
228
|
+
* @returns {Object[]}
|
|
229
|
+
*/
|
|
230
|
+
export function getArchivalCandidates(inactiveDays = 60, cwd = process.cwd()) {
|
|
231
|
+
const registry = getKnowledgeRegistry(cwd);
|
|
232
|
+
if (!registry) return [];
|
|
233
|
+
|
|
234
|
+
const candidates = [];
|
|
235
|
+
const cutoff = new Date(Date.now() - inactiveDays * 24 * 60 * 60 * 1000);
|
|
236
|
+
|
|
237
|
+
for (const [id, item] of Object.entries(registry.items)) {
|
|
238
|
+
if (item.status !== 'active') continue;
|
|
239
|
+
|
|
240
|
+
const lastAccessed = item.lastAccessed ? new Date(item.lastAccessed) : null;
|
|
241
|
+
|
|
242
|
+
// Has references but hasn't been accessed
|
|
243
|
+
const hasRefs = (item.relatedFiles || []).length > 0 ||
|
|
244
|
+
(item.relatedDecisions || []).length > 0;
|
|
245
|
+
|
|
246
|
+
if (hasRefs && (!lastAccessed || lastAccessed < cutoff)) {
|
|
247
|
+
candidates.push({
|
|
248
|
+
id,
|
|
249
|
+
title: item.title,
|
|
250
|
+
lastAccessed: item.lastAccessed,
|
|
251
|
+
daysSinceAccess: lastAccessed
|
|
252
|
+
? Math.floor((Date.now() - lastAccessed) / (24 * 60 * 60 * 1000))
|
|
253
|
+
: 'never',
|
|
254
|
+
refCount: (item.relatedFiles || []).length + (item.relatedDecisions || []).length
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return candidates.sort((a, b) => {
|
|
260
|
+
const aDate = a.lastAccessed ? new Date(a.lastAccessed) : new Date(0);
|
|
261
|
+
const bDate = b.lastAccessed ? new Date(b.lastAccessed) : new Date(0);
|
|
262
|
+
return aDate - bDate;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Restore an archived item back to active
|
|
268
|
+
* @param {string} id - Item ID
|
|
269
|
+
* @param {string} cwd - Working directory
|
|
270
|
+
* @returns {Object|null}
|
|
271
|
+
*/
|
|
272
|
+
export function restoreFromArchive(id, cwd = process.cwd()) {
|
|
273
|
+
const registry = getKnowledgeRegistry(cwd);
|
|
274
|
+
if (!registry || !registry.items[id]) return null;
|
|
275
|
+
|
|
276
|
+
const item = registry.items[id];
|
|
277
|
+
if (item.status !== 'archived') {
|
|
278
|
+
return { error: `Item ${id} is not archived (status: ${item.status})` };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
item.status = 'active';
|
|
282
|
+
item.restoredAt = new Date().toISOString();
|
|
283
|
+
delete item.archivedAt;
|
|
284
|
+
|
|
285
|
+
saveKnowledgeRegistry(registry, cwd);
|
|
286
|
+
return item;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get GC statistics
|
|
291
|
+
* @param {string} cwd - Working directory
|
|
292
|
+
* @returns {Object}
|
|
293
|
+
*/
|
|
294
|
+
export function getGCStats(cwd = process.cwd()) {
|
|
295
|
+
const registry = getKnowledgeRegistry(cwd);
|
|
296
|
+
const garbage = identifyGarbage(cwd);
|
|
297
|
+
const archivalCandidates = getArchivalCandidates(60, cwd);
|
|
298
|
+
const broken = findBrokenReferences(cwd);
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
lastGC: registry?.lastGC || 'Never',
|
|
302
|
+
garbageCount: garbage.length,
|
|
303
|
+
archivalCandidates: archivalCandidates.length,
|
|
304
|
+
brokenReferences: broken.length,
|
|
305
|
+
garbage: garbage.map(g => ({ id: g.id, reason: g.reason })),
|
|
306
|
+
candidates: archivalCandidates.slice(0, 5)
|
|
307
|
+
};
|
|
308
|
+
}
|