create-claude-cabinet 0.8.4 → 0.9.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/lib/cli.js +154 -10
- package/lib/copy.js +13 -0
- package/lib/omega-setup.js +4 -3
- package/package.json +1 -1
- package/templates/scripts/cabinet-memory-adapter.py +152 -33
- package/templates/scripts/work-tracker-server.mjs +188 -0
- package/templates/scripts/work-tracker-ui.html +948 -0
- package/templates/skills/cabinet-historian/SKILL.md +4 -0
- package/templates/skills/cc-upgrade/SKILL.md +68 -0
- package/templates/skills/debrief/phases/close-work.md +33 -0
- package/templates/skills/execute-plans/SKILL.md +273 -0
- package/templates/skills/execute-plans/scripts/build-conflict-graph.js +281 -0
- package/templates/skills/memory/SKILL.md +14 -5
- package/templates/skills/orient/SKILL.md +59 -4
- package/templates/skills/plan/SKILL.md +11 -2
package/lib/cli.js
CHANGED
|
@@ -33,7 +33,7 @@ const MODULES = {
|
|
|
33
33
|
mandatory: false,
|
|
34
34
|
default: true,
|
|
35
35
|
lean: false,
|
|
36
|
-
templates: ['scripts/pib-db.js', 'scripts/pib-db-schema.sql'],
|
|
36
|
+
templates: ['scripts/pib-db.js', 'scripts/pib-db-schema.sql', 'scripts/work-tracker-server.mjs', 'scripts/work-tracker-ui.html'],
|
|
37
37
|
needsDb: true,
|
|
38
38
|
},
|
|
39
39
|
'planning': {
|
|
@@ -42,7 +42,7 @@ const MODULES = {
|
|
|
42
42
|
mandatory: false,
|
|
43
43
|
default: true,
|
|
44
44
|
lean: true,
|
|
45
|
-
templates: ['skills/plan', 'skills/execute', 'skills/investigate'],
|
|
45
|
+
templates: ['skills/plan', 'skills/execute', 'skills/execute-plans', 'skills/investigate'],
|
|
46
46
|
},
|
|
47
47
|
'compliance': {
|
|
48
48
|
name: 'Compliance Stack (rules + enforcement)',
|
|
@@ -107,6 +107,22 @@ const MODULES = {
|
|
|
107
107
|
},
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
+
/** Recursively collect all relative file paths under a directory. */
|
|
111
|
+
function walkDir(dir, base) {
|
|
112
|
+
if (!base) base = dir;
|
|
113
|
+
const results = [];
|
|
114
|
+
if (!fs.existsSync(dir)) return results;
|
|
115
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
116
|
+
const full = path.join(dir, entry.name);
|
|
117
|
+
if (entry.isDirectory()) {
|
|
118
|
+
results.push(...walkDir(full, base));
|
|
119
|
+
} else {
|
|
120
|
+
results.push(path.relative(base, full));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return results;
|
|
124
|
+
}
|
|
125
|
+
|
|
110
126
|
// Signals that a directory contains a real project (not just empty)
|
|
111
127
|
const PROJECT_SIGNALS = [
|
|
112
128
|
'package.json', 'Cargo.toml', 'requirements.txt', 'pyproject.toml',
|
|
@@ -572,24 +588,152 @@ async function run() {
|
|
|
572
588
|
}
|
|
573
589
|
}
|
|
574
590
|
|
|
575
|
-
// ---
|
|
576
|
-
//
|
|
577
|
-
//
|
|
591
|
+
// --- Manifest key migration (act:d1f16bee) ---
|
|
592
|
+
// When CC renames directories (e.g., perspectives/ → cabinet-*/), old manifest
|
|
593
|
+
// keys no longer match new template paths. Migrate keys BEFORE cleanup so the
|
|
594
|
+
// cleanup loop doesn't treat renamed files as removed.
|
|
595
|
+
if (Object.keys(existingManifest).length > 0) {
|
|
596
|
+
const existing = readMetadata(projectDir);
|
|
597
|
+
const fromVersion = existing.version;
|
|
598
|
+
let migrationCount = 0;
|
|
599
|
+
// v0.5.x → v0.6.x: perspectives/ → cabinet-*/
|
|
600
|
+
if (fromVersion && /^0\.[0-5]\./.test(fromVersion)) {
|
|
601
|
+
for (const key of Object.keys(existingManifest)) {
|
|
602
|
+
const match = key.match(/\.claude\/skills\/perspectives\/([^/]+)\//);
|
|
603
|
+
if (match) {
|
|
604
|
+
const newKey = key.replace(`perspectives/${match[1]}/`, `cabinet-${match[1]}/`);
|
|
605
|
+
existingManifest[newKey] = existingManifest[key];
|
|
606
|
+
delete existingManifest[key];
|
|
607
|
+
migrationCount++;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Future manifest key migrations go here
|
|
612
|
+
if (migrationCount > 0) {
|
|
613
|
+
console.log(` 🔄 Migrated ${migrationCount} manifest key${migrationCount === 1 ? '' : 's'} for directory rename`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// --- Clean up files removed upstream (safeguarded) ---
|
|
618
|
+
// Four safeguards prevent the v0.6.8 incident from recurring:
|
|
619
|
+
// S1: Classify — only delete files that map to a known CC template path
|
|
620
|
+
// S2: Scope — only delete files from modules the user selected
|
|
621
|
+
// S3: Itemize — list all deletions and confirm before proceeding
|
|
622
|
+
// S4: Backup — copy files to .cc-backup/<timestamp>/ before deletion
|
|
578
623
|
if (Object.keys(existingManifest).length > 0) {
|
|
579
|
-
|
|
624
|
+
// S1: Build complete template path set (ALL modules) for classification
|
|
625
|
+
const allTemplatePaths = new Set();
|
|
626
|
+
for (const mod of Object.values(MODULES)) {
|
|
627
|
+
for (const tmpl of mod.templates || []) {
|
|
628
|
+
const srcPath = path.join(templateRoot, tmpl);
|
|
629
|
+
if (fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
|
|
630
|
+
for (const rel of walkDir(srcPath)) {
|
|
631
|
+
allTemplatePaths.add(manifestPath(tmpl) + '/' + rel);
|
|
632
|
+
}
|
|
633
|
+
} else if (fs.existsSync(srcPath)) {
|
|
634
|
+
allTemplatePaths.add(manifestPath(tmpl));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// S2: Build selected-module template paths for scoping
|
|
640
|
+
const selectedTemplatePaths = new Set();
|
|
641
|
+
for (const modKey of selectedModules) {
|
|
642
|
+
const mod = MODULES[modKey];
|
|
643
|
+
for (const tmpl of mod.templates || []) {
|
|
644
|
+
const srcPath = path.join(templateRoot, tmpl);
|
|
645
|
+
if (fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
|
|
646
|
+
for (const rel of walkDir(srcPath)) {
|
|
647
|
+
selectedTemplatePaths.add(manifestPath(tmpl) + '/' + rel);
|
|
648
|
+
}
|
|
649
|
+
} else if (fs.existsSync(srcPath)) {
|
|
650
|
+
selectedTemplatePaths.add(manifestPath(tmpl));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Build CC template skill name set for project-skill guard (act:4ac281ba)
|
|
656
|
+
const ccSkillNames = new Set();
|
|
657
|
+
for (const mod of Object.values(MODULES)) {
|
|
658
|
+
for (const tmpl of mod.templates || []) {
|
|
659
|
+
if (tmpl.startsWith('skills/')) {
|
|
660
|
+
const skillName = tmpl.split('/')[1];
|
|
661
|
+
ccSkillNames.add(skillName);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Collect files that would be removed
|
|
667
|
+
const toRemove = [];
|
|
580
668
|
for (const oldPath of Object.keys(existingManifest)) {
|
|
581
669
|
if (!allManifest[oldPath]) {
|
|
582
670
|
// Skip phase files — they may be user-customized
|
|
583
671
|
if (/\/phases\//.test(oldPath)) continue;
|
|
672
|
+
|
|
673
|
+
// S1: Only delete if the path maps to a known CC template
|
|
674
|
+
if (!allTemplatePaths.has(oldPath)) {
|
|
675
|
+
console.log(` Keeping non-template file: ${oldPath}`);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// S2: Only delete from selected modules (don't purge deselected modules)
|
|
680
|
+
if (!selectedTemplatePaths.has(oldPath)) {
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Project-skill guard: never delete skills CC didn't create
|
|
685
|
+
const skillDirMatch = oldPath.match(/^\.claude\/skills\/([^/]+)\//);
|
|
686
|
+
if (skillDirMatch && !ccSkillNames.has(skillDirMatch[1])) {
|
|
687
|
+
console.log(` Keeping project skill: ${oldPath}`);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
|
|
584
691
|
const fullPath = path.join(projectDir, oldPath);
|
|
585
692
|
if (fs.existsSync(fullPath)) {
|
|
586
|
-
|
|
587
|
-
totalRemoved++;
|
|
693
|
+
toRemove.push(oldPath);
|
|
588
694
|
}
|
|
589
695
|
}
|
|
590
696
|
}
|
|
591
|
-
|
|
592
|
-
|
|
697
|
+
|
|
698
|
+
// S3: Itemize and confirm before deleting
|
|
699
|
+
if (toRemove.length > 0) {
|
|
700
|
+
console.log(`\n Files no longer in upstream (${toRemove.length}):`);
|
|
701
|
+
for (const f of toRemove) {
|
|
702
|
+
console.log(` - ${f}`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
let confirmed = flags.yes;
|
|
706
|
+
if (!confirmed && !flags.dryRun) {
|
|
707
|
+
const response = await prompts({
|
|
708
|
+
type: 'confirm',
|
|
709
|
+
name: 'confirmed',
|
|
710
|
+
message: `Remove ${toRemove.length} file${toRemove.length === 1 ? '' : 's'}?`,
|
|
711
|
+
initial: false,
|
|
712
|
+
});
|
|
713
|
+
confirmed = response.confirmed;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (confirmed && !flags.dryRun) {
|
|
717
|
+
// S4: Backup before deleting
|
|
718
|
+
const backupDir = path.join(projectDir, '.cc-backup', new Date().toISOString().replace(/[:.]/g, '-'));
|
|
719
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
720
|
+
for (const filePath of toRemove) {
|
|
721
|
+
const src = path.join(projectDir, filePath);
|
|
722
|
+
const dest = path.join(backupDir, filePath);
|
|
723
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
724
|
+
fs.copyFileSync(src, dest);
|
|
725
|
+
}
|
|
726
|
+
console.log(` 📦 Backed up ${toRemove.length} file${toRemove.length === 1 ? '' : 's'} to ${path.relative(projectDir, backupDir)}/`);
|
|
727
|
+
|
|
728
|
+
for (const filePath of toRemove) {
|
|
729
|
+
fs.unlinkSync(path.join(projectDir, filePath));
|
|
730
|
+
}
|
|
731
|
+
console.log(` 🧹 Removed ${toRemove.length} file${toRemove.length === 1 ? '' : 's'} no longer in upstream`);
|
|
732
|
+
} else if (flags.dryRun) {
|
|
733
|
+
console.log(` [dry run — ${toRemove.length} file${toRemove.length === 1 ? '' : 's'} would be removed]`);
|
|
734
|
+
} else {
|
|
735
|
+
console.log(' Skipped file removal.');
|
|
736
|
+
}
|
|
593
737
|
}
|
|
594
738
|
}
|
|
595
739
|
|
package/lib/copy.js
CHANGED
|
@@ -45,6 +45,19 @@ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipC
|
|
|
45
45
|
if (fs.existsSync(destPath)) {
|
|
46
46
|
const existing = fs.readFileSync(destPath, 'utf8');
|
|
47
47
|
|
|
48
|
+
// Phase file guard (act:98d74381) — independent of skipPhases flag.
|
|
49
|
+
// If a phase file on disk has been customized (differs from template
|
|
50
|
+
// and isn't empty), never overwrite it regardless of other flags.
|
|
51
|
+
if (relPath.includes('phases' + path.sep) || relPath.includes('phases/')) {
|
|
52
|
+
const trimmedExisting = existing.trim();
|
|
53
|
+
if (trimmedExisting !== '' && trimmedExisting !== incoming.trim()) {
|
|
54
|
+
results.skipped.push(relPath);
|
|
55
|
+
results.manifest[relPath] = hashContent(existing);
|
|
56
|
+
console.log(` Preserved customized phase: ${displayPath}`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
if (existing === incoming) {
|
|
49
62
|
results.skipped.push(relPath);
|
|
50
63
|
results.manifest[relPath] = incomingHash;
|
package/lib/omega-setup.js
CHANGED
|
@@ -7,17 +7,18 @@ const OMEGA_HOME = path.join(os.homedir(), '.claude-cabinet');
|
|
|
7
7
|
const VENV_DIR = path.join(OMEGA_HOME, 'omega-venv');
|
|
8
8
|
const VENV_PYTHON = path.join(VENV_DIR, 'bin', 'python3');
|
|
9
9
|
|
|
10
|
-
// Ordered by preference:
|
|
10
|
+
// Ordered by preference: 3.13 first (ONNX runtime segfaults on 3.14 during
|
|
11
|
+
// shutdown — exit 139, triggers macOS crash dialogs). 3.14 last as fallback.
|
|
11
12
|
// Apple Silicon paths, then Intel Mac paths, then PATH fallback.
|
|
12
13
|
const PYTHON_CANDIDATES = [
|
|
13
|
-
'/opt/homebrew/opt/python@3.14/bin/python3.14',
|
|
14
14
|
'/opt/homebrew/opt/python@3.13/bin/python3.13',
|
|
15
15
|
'/opt/homebrew/opt/python@3.12/bin/python3.12',
|
|
16
16
|
'/opt/homebrew/opt/python@3.11/bin/python3.11',
|
|
17
|
-
'/
|
|
17
|
+
'/opt/homebrew/opt/python@3.14/bin/python3.14',
|
|
18
18
|
'/usr/local/opt/python@3.13/bin/python3.13',
|
|
19
19
|
'/usr/local/opt/python@3.12/bin/python3.12',
|
|
20
20
|
'/usr/local/opt/python@3.11/bin/python3.11',
|
|
21
|
+
'/usr/local/opt/python@3.14/bin/python3.14',
|
|
21
22
|
'/opt/homebrew/bin/python3',
|
|
22
23
|
'/usr/local/bin/python3',
|
|
23
24
|
];
|
package/package.json
CHANGED
|
@@ -149,6 +149,7 @@ def cmd_store():
|
|
|
149
149
|
text: the memory content (required)
|
|
150
150
|
type: event_type for omega (default: "lesson")
|
|
151
151
|
tags: list of tags (stored in metadata, optional)
|
|
152
|
+
project: project name (default: basename of cwd)
|
|
152
153
|
"""
|
|
153
154
|
data = _read_stdin()
|
|
154
155
|
omega = _import_omega()
|
|
@@ -163,12 +164,14 @@ def cmd_store():
|
|
|
163
164
|
|
|
164
165
|
try:
|
|
165
166
|
event_type = data.get("type", "lesson")
|
|
167
|
+
project = data.get("project", os.path.basename(os.getcwd()))
|
|
166
168
|
metadata = {}
|
|
167
169
|
if data.get("tags"):
|
|
168
170
|
metadata["tags"] = data["tags"]
|
|
169
171
|
result = omega.store(
|
|
170
172
|
text,
|
|
171
173
|
event_type=event_type,
|
|
174
|
+
project=project,
|
|
172
175
|
metadata=metadata if metadata else None,
|
|
173
176
|
)
|
|
174
177
|
_output({"ok": True, "id": result if isinstance(result, str) else None})
|
|
@@ -177,11 +180,17 @@ def cmd_store():
|
|
|
177
180
|
|
|
178
181
|
|
|
179
182
|
def cmd_query():
|
|
180
|
-
"""Query memories by text.
|
|
183
|
+
"""Query memories by text with tiered project scoping.
|
|
181
184
|
|
|
182
185
|
Reads JSON from stdin with fields:
|
|
183
186
|
text: the query text (required)
|
|
184
187
|
limit: max results (default: 5)
|
|
188
|
+
type: filter by event_type (optional)
|
|
189
|
+
project: project name for scoping (default: basename of cwd)
|
|
190
|
+
scope: "project" (default), "all", or "tiered"
|
|
191
|
+
- "project": only memories from this project
|
|
192
|
+
- "all": memories from all projects
|
|
193
|
+
- "tiered": project-scoped first, then cross-project to fill limit
|
|
185
194
|
"""
|
|
186
195
|
data = _read_stdin()
|
|
187
196
|
omega = _import_omega()
|
|
@@ -197,29 +206,131 @@ def cmd_query():
|
|
|
197
206
|
try:
|
|
198
207
|
limit = data.get("limit", 5)
|
|
199
208
|
event_type = data.get("type")
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
project = data.get("project", os.path.basename(os.getcwd()))
|
|
210
|
+
scope = data.get("scope", "tiered")
|
|
211
|
+
|
|
212
|
+
def _parse_results(results):
|
|
213
|
+
"""Normalize query results into a list of dicts.
|
|
214
|
+
|
|
215
|
+
omega.query() returns a formatted markdown string. We parse it
|
|
216
|
+
back into structured data. Also queries the DB to get the project
|
|
217
|
+
field for each memory (not included in the formatted output).
|
|
218
|
+
"""
|
|
219
|
+
if isinstance(results, (list, tuple)):
|
|
220
|
+
out = []
|
|
221
|
+
for r in results:
|
|
222
|
+
if isinstance(r, dict):
|
|
223
|
+
out.append({
|
|
224
|
+
"text": r.get("content", r.get("text", str(r))),
|
|
225
|
+
"type": r.get("event_type", "unknown"),
|
|
226
|
+
"score": r.get("score", 0),
|
|
227
|
+
"project": r.get("project", ""),
|
|
228
|
+
"id": r.get("node_id", ""),
|
|
229
|
+
})
|
|
230
|
+
else:
|
|
231
|
+
out.append({"text": str(r)})
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
if not isinstance(results, str) or not results.strip():
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
# Parse formatted markdown from omega.query()
|
|
238
|
+
# Format: ## N. [type] `mem-xxx` (str: 0.xx)\ncontent\n*timestamp*
|
|
239
|
+
import re
|
|
240
|
+
entries = []
|
|
241
|
+
blocks = re.split(r'\n## \d+\.', results)
|
|
242
|
+
for block in blocks:
|
|
243
|
+
block = block.strip()
|
|
244
|
+
if not block:
|
|
245
|
+
continue
|
|
246
|
+
id_match = re.search(r'`(mem-[a-f0-9]+)`', block)
|
|
247
|
+
type_match = re.search(r'\[(\w+)\]', block)
|
|
248
|
+
score_match = re.search(r'str: ([\d.]+)', block)
|
|
249
|
+
# Content is everything after the first line, minus the timestamp
|
|
250
|
+
lines = block.split('\n')
|
|
251
|
+
content_lines = [l for l in lines[1:] if not l.startswith('*')]
|
|
252
|
+
content = '\n'.join(content_lines).strip()
|
|
253
|
+
|
|
254
|
+
if not id_match:
|
|
255
|
+
continue # skip header/noise lines
|
|
256
|
+
entry = {
|
|
257
|
+
"text": content,
|
|
258
|
+
"type": type_match.group(1) if type_match else "unknown",
|
|
259
|
+
"score": float(score_match.group(1)) if score_match else 0,
|
|
260
|
+
"id": id_match.group(1) if id_match else "",
|
|
261
|
+
"project": "",
|
|
262
|
+
}
|
|
263
|
+
entries.append(entry)
|
|
264
|
+
|
|
265
|
+
# Enrich with project from DB
|
|
266
|
+
if entries:
|
|
267
|
+
try:
|
|
268
|
+
import sqlite3
|
|
269
|
+
db_path = os.path.expanduser("~/.omega/omega.db")
|
|
270
|
+
conn = sqlite3.connect(db_path)
|
|
271
|
+
ids = [e["id"] for e in entries if e["id"]]
|
|
272
|
+
if ids:
|
|
273
|
+
placeholders = ",".join("?" * len(ids))
|
|
274
|
+
rows = conn.execute(
|
|
275
|
+
f"SELECT node_id, project FROM memories WHERE node_id IN ({placeholders})",
|
|
276
|
+
ids,
|
|
277
|
+
).fetchall()
|
|
278
|
+
proj_map = {r[0]: r[1] or "" for r in rows}
|
|
279
|
+
for e in entries:
|
|
280
|
+
e["project"] = proj_map.get(e["id"], "")
|
|
281
|
+
conn.close()
|
|
282
|
+
except Exception:
|
|
283
|
+
pass # D3: project enrichment is best-effort
|
|
284
|
+
|
|
285
|
+
return entries
|
|
286
|
+
|
|
287
|
+
# All scopes fetch from omega broadly, then filter in the adapter.
|
|
288
|
+
# omega.query() doesn't reliably pass project_path to the store,
|
|
289
|
+
# so we do project filtering ourselves.
|
|
290
|
+
over_fetch = limit * 3 # fetch extra so filtering still yields enough
|
|
291
|
+
|
|
292
|
+
if scope == "all":
|
|
293
|
+
results = omega.query(text, limit=over_fetch, event_type=event_type)
|
|
294
|
+
parsed = _parse_results(results)[:limit]
|
|
295
|
+
_output({"ok": True, "results": parsed, "scope": "all"})
|
|
296
|
+
|
|
297
|
+
elif scope == "project":
|
|
298
|
+
results = omega.query(text, limit=over_fetch, event_type=event_type)
|
|
299
|
+
parsed = _parse_results(results)
|
|
300
|
+
filtered = [m for m in parsed if m.get("project") == project][:limit]
|
|
301
|
+
_output({"ok": True, "results": filtered, "scope": "project", "project": project})
|
|
302
|
+
|
|
303
|
+
else: # tiered (default)
|
|
304
|
+
results = omega.query(text, limit=over_fetch, event_type=event_type)
|
|
305
|
+
parsed = _parse_results(results)
|
|
306
|
+
|
|
307
|
+
# Tier 1: project-scoped
|
|
308
|
+
project_memories = [m for m in parsed if m.get("project") == project][:limit]
|
|
309
|
+
for m in project_memories:
|
|
310
|
+
m["tier"] = "project"
|
|
311
|
+
|
|
312
|
+
# Tier 2: cross-project to fill remaining slots
|
|
313
|
+
remaining = limit - len(project_memories)
|
|
314
|
+
cross_memories = []
|
|
315
|
+
if remaining > 0:
|
|
316
|
+
seen_ids = {m["id"] for m in project_memories if m["id"]}
|
|
317
|
+
for m in parsed:
|
|
318
|
+
if m.get("project") != project and m.get("id") not in seen_ids:
|
|
319
|
+
m["tier"] = "cross-project"
|
|
320
|
+
cross_memories.append(m)
|
|
321
|
+
if len(cross_memories) >= remaining:
|
|
322
|
+
break
|
|
323
|
+
|
|
324
|
+
combined = project_memories + cross_memories
|
|
325
|
+
_output({
|
|
326
|
+
"ok": True,
|
|
327
|
+
"results": combined,
|
|
328
|
+
"scope": "tiered",
|
|
329
|
+
"project": project,
|
|
330
|
+
"project_count": len(project_memories),
|
|
331
|
+
"cross_project_count": len(cross_memories),
|
|
332
|
+
})
|
|
205
333
|
|
|
206
|
-
# query() returns a formatted string of results
|
|
207
|
-
if isinstance(results, str):
|
|
208
|
-
_output({"ok": True, "results": results})
|
|
209
|
-
elif isinstance(results, (list, tuple)):
|
|
210
|
-
memories = []
|
|
211
|
-
for r in results:
|
|
212
|
-
if isinstance(r, dict):
|
|
213
|
-
memories.append({
|
|
214
|
-
"text": r.get("content", r.get("text", str(r))),
|
|
215
|
-
"type": r.get("event_type", "unknown"),
|
|
216
|
-
"score": r.get("score", 0),
|
|
217
|
-
})
|
|
218
|
-
else:
|
|
219
|
-
memories.append({"text": str(r)})
|
|
220
|
-
_output({"ok": True, "memories": memories})
|
|
221
|
-
else:
|
|
222
|
-
_output({"ok": True, "results": str(results)})
|
|
223
334
|
except Exception as e:
|
|
224
335
|
_error(f"query failed: {e}")
|
|
225
336
|
|
|
@@ -278,6 +389,7 @@ def cmd_list():
|
|
|
278
389
|
Returns all memories so delete can target the correct ID.
|
|
279
390
|
Reads JSON from stdin with optional fields:
|
|
280
391
|
type: filter by event_type (optional)
|
|
392
|
+
project: filter by project (optional)
|
|
281
393
|
limit: max results (default: 50)
|
|
282
394
|
"""
|
|
283
395
|
data = _read_stdin()
|
|
@@ -291,21 +403,28 @@ def cmd_list():
|
|
|
291
403
|
|
|
292
404
|
conn = sqlite3.connect(db_path)
|
|
293
405
|
event_type = data.get("type")
|
|
406
|
+
project = data.get("project")
|
|
294
407
|
limit = data.get("limit", 50)
|
|
295
408
|
|
|
409
|
+
where_clauses = []
|
|
410
|
+
params = []
|
|
296
411
|
if event_type:
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
412
|
+
where_clauses.append("event_type = ?")
|
|
413
|
+
params.append(event_type)
|
|
414
|
+
if project:
|
|
415
|
+
where_clauses.append("project = ?")
|
|
416
|
+
params.append(project)
|
|
417
|
+
|
|
418
|
+
where = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
|
419
|
+
params.append(limit)
|
|
420
|
+
|
|
421
|
+
rows = conn.execute(
|
|
422
|
+
f"SELECT node_id, content, event_type, project, created_at FROM memories {where} ORDER BY created_at DESC LIMIT ?",
|
|
423
|
+
params,
|
|
424
|
+
).fetchall()
|
|
306
425
|
|
|
307
426
|
memories = [
|
|
308
|
-
{"id": r[0], "text": r[1], "type": r[2], "
|
|
427
|
+
{"id": r[0], "text": r[1], "type": r[2], "project": r[3] or "", "created": r[4]}
|
|
309
428
|
for r in rows
|
|
310
429
|
]
|
|
311
430
|
_output({"ok": True, "memories": memories, "count": len(memories)})
|