@jacques-ai/core 0.0.7-alpha.1
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/dist/archive/archive-store.d.ts +166 -0
- package/dist/archive/archive-store.d.ts.map +1 -0
- package/dist/archive/archive-store.js +612 -0
- package/dist/archive/archive-store.js.map +1 -0
- package/dist/archive/bulk-archive.d.ts +63 -0
- package/dist/archive/bulk-archive.d.ts.map +1 -0
- package/dist/archive/bulk-archive.js +315 -0
- package/dist/archive/bulk-archive.js.map +1 -0
- package/dist/archive/filename-utils.d.ts +39 -0
- package/dist/archive/filename-utils.d.ts.map +1 -0
- package/dist/archive/filename-utils.js +78 -0
- package/dist/archive/filename-utils.js.map +1 -0
- package/dist/archive/index.d.ts +21 -0
- package/dist/archive/index.d.ts.map +1 -0
- package/dist/archive/index.js +45 -0
- package/dist/archive/index.js.map +1 -0
- package/dist/archive/manifest-extractor.d.ts +40 -0
- package/dist/archive/manifest-extractor.d.ts.map +1 -0
- package/dist/archive/manifest-extractor.js +456 -0
- package/dist/archive/manifest-extractor.js.map +1 -0
- package/dist/archive/migration.d.ts +59 -0
- package/dist/archive/migration.d.ts.map +1 -0
- package/dist/archive/migration.js +172 -0
- package/dist/archive/migration.js.map +1 -0
- package/dist/archive/plan-cataloger.d.ts +24 -0
- package/dist/archive/plan-cataloger.d.ts.map +1 -0
- package/dist/archive/plan-cataloger.js +100 -0
- package/dist/archive/plan-cataloger.js.map +1 -0
- package/dist/archive/plan-extractor.d.ts +84 -0
- package/dist/archive/plan-extractor.d.ts.map +1 -0
- package/dist/archive/plan-extractor.js +371 -0
- package/dist/archive/plan-extractor.js.map +1 -0
- package/dist/archive/search-indexer.d.ts +50 -0
- package/dist/archive/search-indexer.d.ts.map +1 -0
- package/dist/archive/search-indexer.js +294 -0
- package/dist/archive/search-indexer.js.map +1 -0
- package/dist/archive/subagent-store.d.ts +113 -0
- package/dist/archive/subagent-store.d.ts.map +1 -0
- package/dist/archive/subagent-store.js +173 -0
- package/dist/archive/subagent-store.js.map +1 -0
- package/dist/archive/types.d.ts +236 -0
- package/dist/archive/types.d.ts.map +1 -0
- package/dist/archive/types.js +30 -0
- package/dist/archive/types.js.map +1 -0
- package/dist/branding.d.ts +9 -0
- package/dist/branding.d.ts.map +1 -0
- package/dist/branding.js +50 -0
- package/dist/branding.js.map +1 -0
- package/dist/cache/git-utils.d.ts +36 -0
- package/dist/cache/git-utils.d.ts.map +1 -0
- package/dist/cache/git-utils.js +160 -0
- package/dist/cache/git-utils.js.map +1 -0
- package/dist/cache/hidden-projects.d.ts +19 -0
- package/dist/cache/hidden-projects.d.ts.map +1 -0
- package/dist/cache/hidden-projects.js +48 -0
- package/dist/cache/hidden-projects.js.map +1 -0
- package/dist/cache/index.d.ts +15 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +20 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/cache/metadata-extractor.d.ts +62 -0
- package/dist/cache/metadata-extractor.d.ts.map +1 -0
- package/dist/cache/metadata-extractor.js +574 -0
- package/dist/cache/metadata-extractor.js.map +1 -0
- package/dist/cache/mode-detector.d.ts +19 -0
- package/dist/cache/mode-detector.d.ts.map +1 -0
- package/dist/cache/mode-detector.js +161 -0
- package/dist/cache/mode-detector.js.map +1 -0
- package/dist/cache/persistence.d.ts +39 -0
- package/dist/cache/persistence.d.ts.map +1 -0
- package/dist/cache/persistence.js +98 -0
- package/dist/cache/persistence.js.map +1 -0
- package/dist/cache/project-discovery.d.ts +41 -0
- package/dist/cache/project-discovery.d.ts.map +1 -0
- package/dist/cache/project-discovery.js +212 -0
- package/dist/cache/project-discovery.js.map +1 -0
- package/dist/cache/session-index.d.ts +258 -0
- package/dist/cache/session-index.d.ts.map +1 -0
- package/dist/cache/session-index.js +1030 -0
- package/dist/cache/session-index.js.map +1 -0
- package/dist/cache/types.d.ts +159 -0
- package/dist/cache/types.d.ts.map +1 -0
- package/dist/cache/types.js +29 -0
- package/dist/cache/types.js.map +1 -0
- package/dist/catalog/bulk-extractor.d.ts +18 -0
- package/dist/catalog/bulk-extractor.d.ts.map +1 -0
- package/dist/catalog/bulk-extractor.js +150 -0
- package/dist/catalog/bulk-extractor.js.map +1 -0
- package/dist/catalog/extractor.d.ts +53 -0
- package/dist/catalog/extractor.d.ts.map +1 -0
- package/dist/catalog/extractor.js +522 -0
- package/dist/catalog/extractor.js.map +1 -0
- package/dist/catalog/index.d.ts +10 -0
- package/dist/catalog/index.d.ts.map +1 -0
- package/dist/catalog/index.js +11 -0
- package/dist/catalog/index.js.map +1 -0
- package/dist/catalog/types.d.ts +134 -0
- package/dist/catalog/types.d.ts.map +1 -0
- package/dist/catalog/types.js +8 -0
- package/dist/catalog/types.js.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/websocket-client.d.ts +96 -0
- package/dist/client/websocket-client.d.ts.map +1 -0
- package/dist/client/websocket-client.js +222 -0
- package/dist/client/websocket-client.js.map +1 -0
- package/dist/context/index.d.ts +13 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +26 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/indexer.d.ts +73 -0
- package/dist/context/indexer.d.ts.map +1 -0
- package/dist/context/indexer.js +233 -0
- package/dist/context/indexer.js.map +1 -0
- package/dist/context/manager.d.ts +66 -0
- package/dist/context/manager.d.ts.map +1 -0
- package/dist/context/manager.js +310 -0
- package/dist/context/manager.js.map +1 -0
- package/dist/context/types.d.ts +149 -0
- package/dist/context/types.d.ts.map +1 -0
- package/dist/context/types.js +36 -0
- package/dist/context/types.js.map +1 -0
- package/dist/handoff/catalog.d.ts +54 -0
- package/dist/handoff/catalog.d.ts.map +1 -0
- package/dist/handoff/catalog.js +121 -0
- package/dist/handoff/catalog.js.map +1 -0
- package/dist/handoff/generator.d.ts +107 -0
- package/dist/handoff/generator.d.ts.map +1 -0
- package/dist/handoff/generator.js +603 -0
- package/dist/handoff/generator.js.map +1 -0
- package/dist/handoff/index.d.ts +13 -0
- package/dist/handoff/index.d.ts.map +1 -0
- package/dist/handoff/index.js +12 -0
- package/dist/handoff/index.js.map +1 -0
- package/dist/handoff/llm-generator.d.ts +77 -0
- package/dist/handoff/llm-generator.d.ts.map +1 -0
- package/dist/handoff/llm-generator.js +513 -0
- package/dist/handoff/llm-generator.js.map +1 -0
- package/dist/handoff/prompts.d.ts +18 -0
- package/dist/handoff/prompts.d.ts.map +1 -0
- package/dist/handoff/prompts.js +22 -0
- package/dist/handoff/prompts.js.map +1 -0
- package/dist/handoff/types.d.ts +28 -0
- package/dist/handoff/types.d.ts.map +1 -0
- package/dist/handoff/types.js +7 -0
- package/dist/handoff/types.js.map +1 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/logging/claude-operations.d.ts +111 -0
- package/dist/logging/claude-operations.d.ts.map +1 -0
- package/dist/logging/claude-operations.js +132 -0
- package/dist/logging/claude-operations.js.map +1 -0
- package/dist/logging/error-utils.d.ts +18 -0
- package/dist/logging/error-utils.d.ts.map +1 -0
- package/dist/logging/error-utils.js +37 -0
- package/dist/logging/error-utils.js.map +1 -0
- package/dist/logging/index.d.ts +11 -0
- package/dist/logging/index.d.ts.map +1 -0
- package/dist/logging/index.js +10 -0
- package/dist/logging/index.js.map +1 -0
- package/dist/logging/logger.d.ts +25 -0
- package/dist/logging/logger.d.ts.map +1 -0
- package/dist/logging/logger.js +39 -0
- package/dist/logging/logger.js.map +1 -0
- package/dist/notifications/constants.d.ts +16 -0
- package/dist/notifications/constants.d.ts.map +1 -0
- package/dist/notifications/constants.js +49 -0
- package/dist/notifications/constants.js.map +1 -0
- package/dist/notifications/index.d.ts +9 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +8 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/notifications/types.d.ts +28 -0
- package/dist/notifications/types.d.ts.map +1 -0
- package/dist/notifications/types.js +7 -0
- package/dist/notifications/types.js.map +1 -0
- package/dist/notifications/utils.d.ts +20 -0
- package/dist/notifications/utils.d.ts.map +1 -0
- package/dist/notifications/utils.js +37 -0
- package/dist/notifications/utils.js.map +1 -0
- package/dist/plan/index.d.ts +12 -0
- package/dist/plan/index.d.ts.map +1 -0
- package/dist/plan/index.js +15 -0
- package/dist/plan/index.js.map +1 -0
- package/dist/plan/plan-parser.d.ts +33 -0
- package/dist/plan/plan-parser.d.ts.map +1 -0
- package/dist/plan/plan-parser.js +189 -0
- package/dist/plan/plan-parser.js.map +1 -0
- package/dist/plan/progress-computer.d.ts +34 -0
- package/dist/plan/progress-computer.d.ts.map +1 -0
- package/dist/plan/progress-computer.js +211 -0
- package/dist/plan/progress-computer.js.map +1 -0
- package/dist/plan/progress-matcher.d.ts +34 -0
- package/dist/plan/progress-matcher.d.ts.map +1 -0
- package/dist/plan/progress-matcher.js +297 -0
- package/dist/plan/progress-matcher.js.map +1 -0
- package/dist/plan/task-extractor.d.ts +30 -0
- package/dist/plan/task-extractor.d.ts.map +1 -0
- package/dist/plan/task-extractor.js +435 -0
- package/dist/plan/task-extractor.js.map +1 -0
- package/dist/plan/types.d.ts +131 -0
- package/dist/plan/types.d.ts.map +1 -0
- package/dist/plan/types.js +8 -0
- package/dist/plan/types.js.map +1 -0
- package/dist/project/aggregator.d.ts +43 -0
- package/dist/project/aggregator.d.ts.map +1 -0
- package/dist/project/aggregator.js +218 -0
- package/dist/project/aggregator.js.map +1 -0
- package/dist/project/index.d.ts +9 -0
- package/dist/project/index.d.ts.map +1 -0
- package/dist/project/index.js +9 -0
- package/dist/project/index.js.map +1 -0
- package/dist/project/types.d.ts +65 -0
- package/dist/project/types.d.ts.map +1 -0
- package/dist/project/types.js +27 -0
- package/dist/project/types.js.map +1 -0
- package/dist/session/detector.d.ts +113 -0
- package/dist/session/detector.d.ts.map +1 -0
- package/dist/session/detector.js +333 -0
- package/dist/session/detector.js.map +1 -0
- package/dist/session/filters.d.ts +32 -0
- package/dist/session/filters.d.ts.map +1 -0
- package/dist/session/filters.js +100 -0
- package/dist/session/filters.js.map +1 -0
- package/dist/session/format-title.d.ts +16 -0
- package/dist/session/format-title.d.ts.map +1 -0
- package/dist/session/format-title.js +54 -0
- package/dist/session/format-title.js.map +1 -0
- package/dist/session/index.d.ts +16 -0
- package/dist/session/index.d.ts.map +1 -0
- package/dist/session/index.js +10 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/parser.d.ts +264 -0
- package/dist/session/parser.d.ts.map +1 -0
- package/dist/session/parser.js +588 -0
- package/dist/session/parser.js.map +1 -0
- package/dist/session/token-estimator.d.ts +32 -0
- package/dist/session/token-estimator.d.ts.map +1 -0
- package/dist/session/token-estimator.js +139 -0
- package/dist/session/token-estimator.js.map +1 -0
- package/dist/session/transformer.d.ts +126 -0
- package/dist/session/transformer.d.ts.map +1 -0
- package/dist/session/transformer.js +158 -0
- package/dist/session/transformer.js.map +1 -0
- package/dist/setup/hooks-config.d.ts +35 -0
- package/dist/setup/hooks-config.d.ts.map +1 -0
- package/dist/setup/hooks-config.js +107 -0
- package/dist/setup/hooks-config.js.map +1 -0
- package/dist/setup/hooks-symlink.d.ts +17 -0
- package/dist/setup/hooks-symlink.d.ts.map +1 -0
- package/dist/setup/hooks-symlink.js +89 -0
- package/dist/setup/hooks-symlink.js.map +1 -0
- package/dist/setup/index.d.ts +13 -0
- package/dist/setup/index.d.ts.map +1 -0
- package/dist/setup/index.js +12 -0
- package/dist/setup/index.js.map +1 -0
- package/dist/setup/prerequisites.d.ts +11 -0
- package/dist/setup/prerequisites.d.ts.map +1 -0
- package/dist/setup/prerequisites.js +72 -0
- package/dist/setup/prerequisites.js.map +1 -0
- package/dist/setup/settings-merge.d.ts +33 -0
- package/dist/setup/settings-merge.d.ts.map +1 -0
- package/dist/setup/settings-merge.js +131 -0
- package/dist/setup/settings-merge.js.map +1 -0
- package/dist/setup/skills-install.d.ts +17 -0
- package/dist/setup/skills-install.d.ts.map +1 -0
- package/dist/setup/skills-install.js +60 -0
- package/dist/setup/skills-install.js.map +1 -0
- package/dist/setup/types.d.ts +39 -0
- package/dist/setup/types.d.ts.map +1 -0
- package/dist/setup/types.js +7 -0
- package/dist/setup/types.js.map +1 -0
- package/dist/setup/verification.d.ts +9 -0
- package/dist/setup/verification.d.ts.map +1 -0
- package/dist/setup/verification.js +91 -0
- package/dist/setup/verification.js.map +1 -0
- package/dist/shortcuts/index.d.ts +8 -0
- package/dist/shortcuts/index.d.ts.map +1 -0
- package/dist/shortcuts/index.js +6 -0
- package/dist/shortcuts/index.js.map +1 -0
- package/dist/shortcuts/key-utils.d.ts +54 -0
- package/dist/shortcuts/key-utils.d.ts.map +1 -0
- package/dist/shortcuts/key-utils.js +129 -0
- package/dist/shortcuts/key-utils.js.map +1 -0
- package/dist/shortcuts/shortcut-registry.d.ts +37 -0
- package/dist/shortcuts/shortcut-registry.d.ts.map +1 -0
- package/dist/shortcuts/shortcut-registry.js +322 -0
- package/dist/shortcuts/shortcut-registry.js.map +1 -0
- package/dist/sources/config.d.ts +91 -0
- package/dist/sources/config.d.ts.map +1 -0
- package/dist/sources/config.js +229 -0
- package/dist/sources/config.js.map +1 -0
- package/dist/sources/googledocs.d.ts +43 -0
- package/dist/sources/googledocs.d.ts.map +1 -0
- package/dist/sources/googledocs.js +298 -0
- package/dist/sources/googledocs.js.map +1 -0
- package/dist/sources/index.d.ts +14 -0
- package/dist/sources/index.d.ts.map +1 -0
- package/dist/sources/index.js +19 -0
- package/dist/sources/index.js.map +1 -0
- package/dist/sources/notion.d.ts +35 -0
- package/dist/sources/notion.d.ts.map +1 -0
- package/dist/sources/notion.js +352 -0
- package/dist/sources/notion.js.map +1 -0
- package/dist/sources/obsidian.d.ts +38 -0
- package/dist/sources/obsidian.d.ts.map +1 -0
- package/dist/sources/obsidian.js +228 -0
- package/dist/sources/obsidian.js.map +1 -0
- package/dist/sources/types.d.ts +133 -0
- package/dist/sources/types.d.ts.map +1 -0
- package/dist/sources/types.js +19 -0
- package/dist/sources/types.js.map +1 -0
- package/dist/storage/index.d.ts +6 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +5 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/writer.d.ts +86 -0
- package/dist/storage/writer.d.ts.map +1 -0
- package/dist/storage/writer.js +137 -0
- package/dist/storage/writer.js.map +1 -0
- package/dist/types.d.ts +203 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/claude-token.d.ts +49 -0
- package/dist/utils/claude-token.d.ts.map +1 -0
- package/dist/utils/claude-token.js +169 -0
- package/dist/utils/claude-token.js.map +1 -0
- package/dist/utils/index.d.ts +7 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +13 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/settings.d.ts +100 -0
- package/dist/utils/settings.d.ts.map +1 -0
- package/dist/utils/settings.js +206 -0
- package/dist/utils/settings.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Index
|
|
3
|
+
*
|
|
4
|
+
* Lightweight index for fast session listing and search.
|
|
5
|
+
* Reads directly from Claude Code JSONL files - no content copying.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* ~/.claude/projects/... (SOURCE OF TRUTH)
|
|
9
|
+
* ↓ read directly
|
|
10
|
+
* GUI Viewer
|
|
11
|
+
* ↑
|
|
12
|
+
* ~/.jacques/cache/
|
|
13
|
+
* └── sessions-index.json (~5KB, metadata only)
|
|
14
|
+
*/
|
|
15
|
+
import { promises as fs } from "fs";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import { homedir } from "os";
|
|
18
|
+
import { execSync } from "child_process";
|
|
19
|
+
import { parseJSONL, getEntryStatistics } from "../session/parser.js";
|
|
20
|
+
import { listSubagentFiles, decodeProjectPath, getClaudeProjectsDir } from "../session/detector.js";
|
|
21
|
+
import { PLAN_TRIGGER_PATTERNS, extractPlanTitle } from "../archive/plan-extractor.js";
|
|
22
|
+
import { readProjectIndex } from "../context/indexer.js";
|
|
23
|
+
/** Claude projects directory (resolved via config/env) */
|
|
24
|
+
const CLAUDE_PROJECTS_PATH = getClaudeProjectsDir();
|
|
25
|
+
/** Jacques cache directory */
|
|
26
|
+
const JACQUES_CACHE_PATH = path.join(homedir(), ".jacques", "cache");
|
|
27
|
+
/** Session index filename */
|
|
28
|
+
const SESSION_INDEX_FILE = "sessions-index.json";
|
|
29
|
+
/**
|
|
30
|
+
* Get default empty session index
|
|
31
|
+
*/
|
|
32
|
+
export function getDefaultSessionIndex() {
|
|
33
|
+
return {
|
|
34
|
+
version: "2.0.0",
|
|
35
|
+
lastScanned: new Date().toISOString(),
|
|
36
|
+
sessions: [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// Re-export for backwards compatibility (cache/index.ts exports this)
|
|
40
|
+
export { decodeProjectPath };
|
|
41
|
+
/**
|
|
42
|
+
* Get the cache directory path
|
|
43
|
+
*/
|
|
44
|
+
export function getCacheDir() {
|
|
45
|
+
return JACQUES_CACHE_PATH;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Get the session index file path
|
|
49
|
+
*/
|
|
50
|
+
export function getIndexPath() {
|
|
51
|
+
return path.join(JACQUES_CACHE_PATH, SESSION_INDEX_FILE);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Ensure cache directory exists
|
|
55
|
+
*/
|
|
56
|
+
export async function ensureCacheDir() {
|
|
57
|
+
await fs.mkdir(JACQUES_CACHE_PATH, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Read the session index from disk
|
|
61
|
+
*/
|
|
62
|
+
export async function readSessionIndex() {
|
|
63
|
+
try {
|
|
64
|
+
const indexPath = getIndexPath();
|
|
65
|
+
const content = await fs.readFile(indexPath, "utf-8");
|
|
66
|
+
return JSON.parse(content);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return getDefaultSessionIndex();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Write the session index to disk
|
|
74
|
+
*/
|
|
75
|
+
export async function writeSessionIndex(index) {
|
|
76
|
+
await ensureCacheDir();
|
|
77
|
+
const indexPath = getIndexPath();
|
|
78
|
+
await fs.writeFile(indexPath, JSON.stringify(index, null, 2), "utf-8");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Extract session title from parsed JSONL entries.
|
|
82
|
+
* Priority:
|
|
83
|
+
* 1. Summary entry (Claude's auto-generated title)
|
|
84
|
+
* 2. First real user message (skips internal command messages)
|
|
85
|
+
*/
|
|
86
|
+
function extractTitle(entries) {
|
|
87
|
+
// Try summary first
|
|
88
|
+
const summaryEntry = entries.find((e) => e.type === "summary" && e.content.summary);
|
|
89
|
+
if (summaryEntry?.content.summary) {
|
|
90
|
+
return summaryEntry.content.summary;
|
|
91
|
+
}
|
|
92
|
+
// Fallback to first real user message (skip internal command messages)
|
|
93
|
+
const userMessage = entries.find((e) => {
|
|
94
|
+
if (e.type !== "user_message" || !e.content.text)
|
|
95
|
+
return false;
|
|
96
|
+
const text = e.content.text.trim();
|
|
97
|
+
// Skip internal Claude Code messages
|
|
98
|
+
if (text.startsWith("<local-command"))
|
|
99
|
+
return false;
|
|
100
|
+
if (text.startsWith("<command-"))
|
|
101
|
+
return false;
|
|
102
|
+
if (text.length === 0)
|
|
103
|
+
return false;
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
if (userMessage?.content.text) {
|
|
107
|
+
// Truncate long messages
|
|
108
|
+
const text = userMessage.content.text.trim();
|
|
109
|
+
if (text.length > 100) {
|
|
110
|
+
return text.slice(0, 97) + "...";
|
|
111
|
+
}
|
|
112
|
+
return text;
|
|
113
|
+
}
|
|
114
|
+
return "Untitled Session";
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Extract timestamps from entries
|
|
118
|
+
*/
|
|
119
|
+
function extractTimestamps(entries) {
|
|
120
|
+
if (entries.length === 0) {
|
|
121
|
+
const now = new Date().toISOString();
|
|
122
|
+
return { startedAt: now, endedAt: now };
|
|
123
|
+
}
|
|
124
|
+
// Find earliest and latest timestamps
|
|
125
|
+
let startedAt = entries[0].timestamp;
|
|
126
|
+
let endedAt = entries[0].timestamp;
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (entry.timestamp < startedAt) {
|
|
129
|
+
startedAt = entry.timestamp;
|
|
130
|
+
}
|
|
131
|
+
if (entry.timestamp > endedAt) {
|
|
132
|
+
endedAt = entry.timestamp;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { startedAt, endedAt };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Detect session mode (planning vs execution) and extract plan references.
|
|
139
|
+
*
|
|
140
|
+
* - Planning mode: EnterPlanMode tool was called during session
|
|
141
|
+
* - Execution mode: First user message contains plan trigger pattern
|
|
142
|
+
*/
|
|
143
|
+
export function detectModeAndPlans(entries) {
|
|
144
|
+
let mode = null;
|
|
145
|
+
const planRefs = [];
|
|
146
|
+
// Track if EnterPlanMode was called (planning mode)
|
|
147
|
+
let hasEnterPlanMode = false;
|
|
148
|
+
// Track first real user message for execution mode detection
|
|
149
|
+
let firstUserMessageChecked = false;
|
|
150
|
+
entries.forEach((entry, index) => {
|
|
151
|
+
// Check for EnterPlanMode tool call (planning mode)
|
|
152
|
+
if (entry.type === 'tool_call' && entry.content.toolName === 'EnterPlanMode') {
|
|
153
|
+
hasEnterPlanMode = true;
|
|
154
|
+
}
|
|
155
|
+
// Check first user message for execution mode
|
|
156
|
+
if (entry.type === 'user_message' && entry.content.text && !firstUserMessageChecked) {
|
|
157
|
+
const text = entry.content.text.trim();
|
|
158
|
+
// Skip internal command messages
|
|
159
|
+
if (text.startsWith('<local-command') ||
|
|
160
|
+
text.startsWith('<command-') ||
|
|
161
|
+
text.length === 0) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
firstUserMessageChecked = true;
|
|
165
|
+
// Check if first message matches plan trigger patterns
|
|
166
|
+
for (const pattern of PLAN_TRIGGER_PATTERNS) {
|
|
167
|
+
if (pattern.test(text)) {
|
|
168
|
+
mode = 'execution';
|
|
169
|
+
// Extract plan content and title
|
|
170
|
+
const match = text.match(pattern);
|
|
171
|
+
if (match) {
|
|
172
|
+
const planContent = text.substring(match[0].length).trim();
|
|
173
|
+
// Only count as plan if it has content with markdown heading
|
|
174
|
+
if (planContent.length >= 100 && planContent.includes('#')) {
|
|
175
|
+
const title = extractPlanTitle(planContent);
|
|
176
|
+
planRefs.push({
|
|
177
|
+
title,
|
|
178
|
+
source: 'embedded',
|
|
179
|
+
messageIndex: index,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Check for embedded plans in other user messages (not just first)
|
|
188
|
+
if (entry.type === 'user_message' && entry.content.text && firstUserMessageChecked) {
|
|
189
|
+
const text = entry.content.text.trim();
|
|
190
|
+
// Skip internal command messages
|
|
191
|
+
if (text.startsWith('<local-command') ||
|
|
192
|
+
text.startsWith('<command-') ||
|
|
193
|
+
text.length === 0) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Check for plan trigger patterns in subsequent messages
|
|
197
|
+
for (const pattern of PLAN_TRIGGER_PATTERNS) {
|
|
198
|
+
if (pattern.test(text)) {
|
|
199
|
+
const match = text.match(pattern);
|
|
200
|
+
if (match) {
|
|
201
|
+
const planContent = text.substring(match[0].length).trim();
|
|
202
|
+
if (planContent.length >= 100 && planContent.includes('#')) {
|
|
203
|
+
const title = extractPlanTitle(planContent);
|
|
204
|
+
// Avoid duplicate entries for the same message
|
|
205
|
+
if (!planRefs.some(r => r.messageIndex === index)) {
|
|
206
|
+
planRefs.push({
|
|
207
|
+
title,
|
|
208
|
+
source: 'embedded',
|
|
209
|
+
messageIndex: index,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Check for Plan agent responses from agent_progress entries
|
|
219
|
+
if (entry.type === 'agent_progress' && entry.content.agentType === 'Plan') {
|
|
220
|
+
const agentId = entry.content.agentId;
|
|
221
|
+
if (agentId && !planRefs.some(r => r.source === 'agent' && r.agentId === agentId)) {
|
|
222
|
+
planRefs.push({
|
|
223
|
+
title: entry.content.agentDescription || 'Agent-Generated Plan',
|
|
224
|
+
source: 'agent',
|
|
225
|
+
messageIndex: index,
|
|
226
|
+
agentId,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Check for Write tool calls to plan files
|
|
231
|
+
if (entry.type === 'tool_call' && entry.content.toolName === 'Write') {
|
|
232
|
+
const input = entry.content.toolInput;
|
|
233
|
+
const filePath = input?.file_path || '';
|
|
234
|
+
const content = input?.content || '';
|
|
235
|
+
// Skip code files - they're not plans even if "plan" is in the name
|
|
236
|
+
const codeExtensions = [
|
|
237
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
238
|
+
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
|
|
239
|
+
'.c', '.cpp', '.h', '.hpp', '.cs', '.php',
|
|
240
|
+
'.vue', '.svelte', '.astro',
|
|
241
|
+
'.css', '.scss', '.less', '.sass',
|
|
242
|
+
'.html', '.htm', '.xml', '.svg',
|
|
243
|
+
'.json', '.yaml', '.yml', '.toml',
|
|
244
|
+
'.sh', '.bash', '.zsh', '.fish',
|
|
245
|
+
'.sql', '.graphql', '.prisma',
|
|
246
|
+
];
|
|
247
|
+
const isCodeFile = codeExtensions.some(ext => filePath.toLowerCase().endsWith(ext));
|
|
248
|
+
if (isCodeFile) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// Check if path looks like a plan file
|
|
252
|
+
const pathLooksLikePlan = filePath.toLowerCase().includes('plan') ||
|
|
253
|
+
filePath.endsWith('.plan.md') ||
|
|
254
|
+
filePath.includes('.jacques/plans/');
|
|
255
|
+
// Check if content looks like markdown plan (not code)
|
|
256
|
+
const hasHeading = /^#+\s+.+/m.test(content);
|
|
257
|
+
const hasListOrParagraph = /^[-*]\s+.+/m.test(content) || content.split('\n\n').length > 1;
|
|
258
|
+
const firstLine = content.split('\n').find(line => line.trim().length > 0) || '';
|
|
259
|
+
const codePatterns = [
|
|
260
|
+
/^import\s+/,
|
|
261
|
+
/^export\s+/,
|
|
262
|
+
/^const\s+/,
|
|
263
|
+
/^function\s+/,
|
|
264
|
+
/^class\s+/,
|
|
265
|
+
/^interface\s+/,
|
|
266
|
+
/^type\s+/,
|
|
267
|
+
];
|
|
268
|
+
const looksLikeCode = codePatterns.some(p => p.test(firstLine.trim()));
|
|
269
|
+
const looksLikeMarkdown = hasHeading && hasListOrParagraph && !looksLikeCode;
|
|
270
|
+
if (pathLooksLikePlan && looksLikeMarkdown) {
|
|
271
|
+
const title = extractPlanTitle(content);
|
|
272
|
+
planRefs.push({
|
|
273
|
+
title,
|
|
274
|
+
source: 'write',
|
|
275
|
+
messageIndex: index,
|
|
276
|
+
filePath,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
// Planning mode takes precedence if EnterPlanMode was called
|
|
282
|
+
if (hasEnterPlanMode) {
|
|
283
|
+
mode = 'planning';
|
|
284
|
+
}
|
|
285
|
+
return { mode, planRefs };
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Extract explore agents and web searches from entries.
|
|
289
|
+
* For explore agents, computes token cost from their subagent JSONL files.
|
|
290
|
+
*/
|
|
291
|
+
async function extractAgentsAndSearches(entries, subagentFiles) {
|
|
292
|
+
const exploreAgents = [];
|
|
293
|
+
const webSearches = [];
|
|
294
|
+
const seenAgentIds = new Set();
|
|
295
|
+
const seenQueries = new Set();
|
|
296
|
+
// Build a map of agentId -> subagent file for quick lookup
|
|
297
|
+
const subagentFileMap = new Map();
|
|
298
|
+
for (const f of subagentFiles) {
|
|
299
|
+
subagentFileMap.set(f.agentId, f);
|
|
300
|
+
}
|
|
301
|
+
for (const entry of entries) {
|
|
302
|
+
// Extract explore agents from agent_progress entries
|
|
303
|
+
if (entry.type === 'agent_progress' && entry.content.agentType === 'Explore') {
|
|
304
|
+
const agentId = entry.content.agentId;
|
|
305
|
+
if (agentId && !seenAgentIds.has(agentId)) {
|
|
306
|
+
seenAgentIds.add(agentId);
|
|
307
|
+
exploreAgents.push({
|
|
308
|
+
id: agentId,
|
|
309
|
+
description: entry.content.agentDescription || 'Explore codebase',
|
|
310
|
+
timestamp: entry.timestamp,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Extract web searches from web_search entries with results
|
|
315
|
+
if (entry.type === 'web_search' && entry.content.searchType === 'results') {
|
|
316
|
+
const query = entry.content.searchQuery;
|
|
317
|
+
if (query && !seenQueries.has(query)) {
|
|
318
|
+
seenQueries.add(query);
|
|
319
|
+
webSearches.push({
|
|
320
|
+
query,
|
|
321
|
+
resultCount: entry.content.searchResultCount || 0,
|
|
322
|
+
timestamp: entry.timestamp,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Compute token costs for explore agents from their subagent JSONL files
|
|
328
|
+
for (const agent of exploreAgents) {
|
|
329
|
+
const subagentFile = subagentFileMap.get(agent.id);
|
|
330
|
+
if (subagentFile) {
|
|
331
|
+
try {
|
|
332
|
+
const subEntries = await parseJSONL(subagentFile.filePath);
|
|
333
|
+
if (subEntries.length > 0) {
|
|
334
|
+
const subStats = getEntryStatistics(subEntries);
|
|
335
|
+
// Total cost = last turn's context window size + estimated output
|
|
336
|
+
const inputCost = subStats.lastInputTokens + subStats.lastCacheRead;
|
|
337
|
+
const outputCost = subStats.totalOutputTokensEstimated;
|
|
338
|
+
agent.tokenCost = inputCost + outputCost;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// Subagent file couldn't be parsed, leave tokenCost undefined
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return { exploreAgents, webSearches };
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Detect git info for a project path: repo root, branch, and worktree name.
|
|
350
|
+
* If the path doesn't exist, walks up parent directories to find a git repo.
|
|
351
|
+
*/
|
|
352
|
+
function detectGitInfo(projectPath) {
|
|
353
|
+
// Try the exact path first, then walk up parents if it doesn't exist
|
|
354
|
+
const candidates = [projectPath];
|
|
355
|
+
let dir = projectPath;
|
|
356
|
+
while (true) {
|
|
357
|
+
const parent = path.dirname(dir);
|
|
358
|
+
if (parent === dir)
|
|
359
|
+
break; // reached filesystem root
|
|
360
|
+
candidates.push(parent);
|
|
361
|
+
dir = parent;
|
|
362
|
+
}
|
|
363
|
+
for (const candidate of candidates) {
|
|
364
|
+
try {
|
|
365
|
+
const output = execSync(`git -C "${candidate}" rev-parse --abbrev-ref HEAD --git-common-dir`, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
366
|
+
if (!output)
|
|
367
|
+
continue;
|
|
368
|
+
const lines = output.split("\n");
|
|
369
|
+
const branch = lines[0] || undefined;
|
|
370
|
+
const commonDir = lines[1];
|
|
371
|
+
if (!commonDir)
|
|
372
|
+
return { branch };
|
|
373
|
+
// Resolve relative paths (e.g., "../.git" from subdirectories) to absolute
|
|
374
|
+
const resolved = path.resolve(candidate, commonDir);
|
|
375
|
+
let repoRoot;
|
|
376
|
+
let worktree;
|
|
377
|
+
if (resolved.endsWith(`${path.sep}.git`) || resolved.endsWith("/.git")) {
|
|
378
|
+
// Normal repo or subdirectory: .git parent is repo root
|
|
379
|
+
repoRoot = path.dirname(resolved);
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
// Worktree: common dir points to shared .git dir
|
|
383
|
+
repoRoot = path.dirname(resolved);
|
|
384
|
+
worktree = path.basename(projectPath);
|
|
385
|
+
}
|
|
386
|
+
return { repoRoot, branch, worktree };
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// This candidate didn't work, try the next parent
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return {};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Read the gitBranch field from early JSONL entries.
|
|
397
|
+
* Used when detectGitInfo fails (e.g., deleted worktrees).
|
|
398
|
+
*/
|
|
399
|
+
async function readGitBranchFromJsonl(jsonlPath) {
|
|
400
|
+
try {
|
|
401
|
+
const handle = await fs.open(jsonlPath, "r");
|
|
402
|
+
try {
|
|
403
|
+
const buf = Buffer.alloc(8192);
|
|
404
|
+
const { bytesRead } = await handle.read(buf, 0, 8192, 0);
|
|
405
|
+
const chunk = buf.toString("utf-8", 0, bytesRead);
|
|
406
|
+
for (const line of chunk.split("\n")) {
|
|
407
|
+
if (!line.trim())
|
|
408
|
+
continue;
|
|
409
|
+
try {
|
|
410
|
+
const entry = JSON.parse(line);
|
|
411
|
+
if (typeof entry.gitBranch === "string") {
|
|
412
|
+
return entry.gitBranch;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// Partial line
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
await handle.close();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
// File unreadable
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Extract metadata from a single JSONL file
|
|
431
|
+
*/
|
|
432
|
+
export async function extractSessionMetadata(jsonlPath, projectPath, projectSlug) {
|
|
433
|
+
try {
|
|
434
|
+
// Get file stats
|
|
435
|
+
const stats = await fs.stat(jsonlPath);
|
|
436
|
+
const sessionId = path.basename(jsonlPath, ".jsonl");
|
|
437
|
+
// Parse JSONL to get metadata
|
|
438
|
+
const entries = await parseJSONL(jsonlPath);
|
|
439
|
+
if (entries.length === 0) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
// Get statistics
|
|
443
|
+
const entryStats = getEntryStatistics(entries);
|
|
444
|
+
// Get timestamps
|
|
445
|
+
const { startedAt, endedAt } = extractTimestamps(entries);
|
|
446
|
+
// Get title
|
|
447
|
+
const title = extractTitle(entries);
|
|
448
|
+
// Check for subagents
|
|
449
|
+
const subagentFiles = await listSubagentFiles(jsonlPath);
|
|
450
|
+
// Filter out internal agents (prompt_suggestion, acompact) from user-visible count
|
|
451
|
+
// These are system agents that shouldn't appear in the subagent count
|
|
452
|
+
const userVisibleSubagents = subagentFiles.filter((f) => !f.agentId.startsWith('aprompt_suggestion-') &&
|
|
453
|
+
!f.agentId.startsWith('acompact-'));
|
|
454
|
+
// Track if auto-compact occurred (for showing indicator in UI)
|
|
455
|
+
const autoCompactFile = subagentFiles.find((f) => f.agentId.startsWith('acompact-'));
|
|
456
|
+
const hadAutoCompact = !!autoCompactFile;
|
|
457
|
+
const autoCompactAt = autoCompactFile?.modifiedAt.toISOString();
|
|
458
|
+
const hasSubagents = userVisibleSubagents.length > 0;
|
|
459
|
+
// Detect mode and plans
|
|
460
|
+
const { mode, planRefs } = detectModeAndPlans(entries);
|
|
461
|
+
// Extract explore agents and web searches (with token costs from subagent files)
|
|
462
|
+
const { exploreAgents, webSearches } = await extractAgentsAndSearches(entries, subagentFiles);
|
|
463
|
+
// Detect git info from project path
|
|
464
|
+
const gitInfo = detectGitInfo(projectPath);
|
|
465
|
+
// If detectGitInfo failed (e.g., deleted worktree), read gitBranch from raw JSONL
|
|
466
|
+
if (!gitInfo.branch) {
|
|
467
|
+
gitInfo.branch = await readGitBranchFromJsonl(jsonlPath) || undefined;
|
|
468
|
+
}
|
|
469
|
+
// Use LAST turn's input tokens for context window size
|
|
470
|
+
// Each turn reports the FULL context, so summing would overcount
|
|
471
|
+
// Total context = fresh input + cache read (cache_creation is subset of fresh, not additional)
|
|
472
|
+
const totalInput = entryStats.lastInputTokens + entryStats.lastCacheRead;
|
|
473
|
+
// Use tiktoken-estimated output tokens (cumulative - each turn generates NEW output)
|
|
474
|
+
const totalOutput = entryStats.totalOutputTokensEstimated;
|
|
475
|
+
const hasTokens = totalInput > 0 || totalOutput > 0;
|
|
476
|
+
return {
|
|
477
|
+
id: sessionId,
|
|
478
|
+
jsonlPath,
|
|
479
|
+
projectPath,
|
|
480
|
+
projectSlug,
|
|
481
|
+
title,
|
|
482
|
+
startedAt,
|
|
483
|
+
endedAt,
|
|
484
|
+
messageCount: entryStats.userMessages + entryStats.assistantMessages,
|
|
485
|
+
toolCallCount: entryStats.toolCalls,
|
|
486
|
+
hasSubagents,
|
|
487
|
+
subagentIds: hasSubagents
|
|
488
|
+
? userVisibleSubagents.map((f) => f.agentId)
|
|
489
|
+
: undefined,
|
|
490
|
+
hadAutoCompact: hadAutoCompact || undefined,
|
|
491
|
+
autoCompactAt: autoCompactAt || undefined,
|
|
492
|
+
tokens: hasTokens ? {
|
|
493
|
+
input: totalInput,
|
|
494
|
+
output: totalOutput,
|
|
495
|
+
cacheCreation: entryStats.lastCacheCreation,
|
|
496
|
+
cacheRead: entryStats.lastCacheRead,
|
|
497
|
+
} : undefined,
|
|
498
|
+
fileSizeBytes: stats.size,
|
|
499
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
500
|
+
mode: mode || undefined,
|
|
501
|
+
planCount: planRefs.length > 0 ? planRefs.length : undefined,
|
|
502
|
+
planRefs: planRefs.length > 0 ? planRefs : undefined,
|
|
503
|
+
gitRepoRoot: gitInfo.repoRoot || undefined,
|
|
504
|
+
gitBranch: gitInfo.branch || undefined,
|
|
505
|
+
gitWorktree: gitInfo.worktree || undefined,
|
|
506
|
+
exploreAgents: exploreAgents.length > 0 ? exploreAgents : undefined,
|
|
507
|
+
webSearches: webSearches.length > 0 ? webSearches : undefined,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* List all project directories in ~/.claude/projects/
|
|
516
|
+
*/
|
|
517
|
+
export async function listAllProjects() {
|
|
518
|
+
const projects = [];
|
|
519
|
+
try {
|
|
520
|
+
const entries = await fs.readdir(CLAUDE_PROJECTS_PATH, {
|
|
521
|
+
withFileTypes: true,
|
|
522
|
+
});
|
|
523
|
+
for (const entry of entries) {
|
|
524
|
+
if (entry.isDirectory()) {
|
|
525
|
+
const projectPath = await decodeProjectPath(entry.name);
|
|
526
|
+
const projectSlug = path.basename(projectPath);
|
|
527
|
+
projects.push({
|
|
528
|
+
encodedPath: path.join(CLAUDE_PROJECTS_PATH, entry.name),
|
|
529
|
+
projectPath,
|
|
530
|
+
projectSlug,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
// Projects directory doesn't exist
|
|
537
|
+
}
|
|
538
|
+
return projects;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Convert a catalog SubagentEntry (type=exploration) to an ExploreAgentRef.
|
|
542
|
+
*/
|
|
543
|
+
function catalogSubagentToExploreRef(entry) {
|
|
544
|
+
return {
|
|
545
|
+
id: entry.id,
|
|
546
|
+
description: entry.title,
|
|
547
|
+
timestamp: entry.timestamp,
|
|
548
|
+
tokenCost: entry.tokenCost,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Convert a catalog SubagentEntry (type=search) to a WebSearchRef.
|
|
553
|
+
*/
|
|
554
|
+
function catalogSubagentToSearchRef(entry) {
|
|
555
|
+
return {
|
|
556
|
+
query: entry.title,
|
|
557
|
+
resultCount: entry.resultCount || 0,
|
|
558
|
+
timestamp: entry.timestamp,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Convert a catalog PlanEntry to a partial PlanRef.
|
|
563
|
+
* messageIndex is set to 0 since catalog doesn't track this.
|
|
564
|
+
*/
|
|
565
|
+
function catalogPlanToPlanRef(plan) {
|
|
566
|
+
return {
|
|
567
|
+
title: plan.title,
|
|
568
|
+
source: "embedded",
|
|
569
|
+
messageIndex: 0,
|
|
570
|
+
catalogId: plan.id,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Read the session manifest JSON from .jacques/sessions/{id}.json.
|
|
575
|
+
* Returns null if file doesn't exist or is unreadable.
|
|
576
|
+
*/
|
|
577
|
+
async function readSessionManifest(projectPath, sessionId) {
|
|
578
|
+
try {
|
|
579
|
+
const manifestPath = path.join(projectPath, ".jacques", "sessions", `${sessionId}.json`);
|
|
580
|
+
const content = await fs.readFile(manifestPath, "utf-8");
|
|
581
|
+
return JSON.parse(content);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Build session entries from catalog data (fast path).
|
|
589
|
+
*
|
|
590
|
+
* For each project:
|
|
591
|
+
* 1. Read .jacques/index.json for catalog metadata
|
|
592
|
+
* 2. List JSONL files in the encoded project dir
|
|
593
|
+
* 3. Stat JSONL files for size/mtime (in parallel)
|
|
594
|
+
* 4. Convert catalog entries to SessionEntry
|
|
595
|
+
* 5. Identify uncataloged JSONL files for fallback parsing
|
|
596
|
+
*/
|
|
597
|
+
async function buildFromCatalog(projects) {
|
|
598
|
+
const catalogSessions = [];
|
|
599
|
+
const uncatalogedFiles = [];
|
|
600
|
+
for (const project of projects) {
|
|
601
|
+
// Read catalog index (returns empty default if missing)
|
|
602
|
+
const index = await readProjectIndex(project.projectPath);
|
|
603
|
+
// List JSONL files in the encoded project directory
|
|
604
|
+
let jsonlFilenames = [];
|
|
605
|
+
try {
|
|
606
|
+
const dirEntries = await fs.readdir(project.encodedPath, { withFileTypes: true });
|
|
607
|
+
jsonlFilenames = dirEntries
|
|
608
|
+
.filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
|
|
609
|
+
.map((e) => e.name);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
continue; // Skip unreadable directories
|
|
613
|
+
}
|
|
614
|
+
// Build set of cataloged session IDs
|
|
615
|
+
const catalogedSessionIds = new Set(index.sessions.map((s) => s.id));
|
|
616
|
+
// Stat all JSONL files in parallel
|
|
617
|
+
const statResults = await Promise.all(jsonlFilenames.map(async (filename) => {
|
|
618
|
+
const jsonlPath = path.join(project.encodedPath, filename);
|
|
619
|
+
const sessionId = path.basename(filename, ".jsonl");
|
|
620
|
+
try {
|
|
621
|
+
const stats = await fs.stat(jsonlPath);
|
|
622
|
+
return { sessionId, jsonlPath, stats, filename };
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return null; // File disappeared
|
|
626
|
+
}
|
|
627
|
+
}));
|
|
628
|
+
for (const result of statResults) {
|
|
629
|
+
if (!result)
|
|
630
|
+
continue;
|
|
631
|
+
const { sessionId, jsonlPath, stats } = result;
|
|
632
|
+
if (!catalogedSessionIds.has(sessionId)) {
|
|
633
|
+
// Not in catalog - needs JSONL parsing
|
|
634
|
+
uncatalogedFiles.push({
|
|
635
|
+
filePath: jsonlPath,
|
|
636
|
+
projectPath: project.projectPath,
|
|
637
|
+
projectSlug: project.projectSlug,
|
|
638
|
+
});
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
// Find catalog session entry
|
|
642
|
+
const catalogSession = index.sessions.find((s) => s.id === sessionId);
|
|
643
|
+
if (!catalogSession)
|
|
644
|
+
continue;
|
|
645
|
+
// Staleness check: if JSONL is newer than catalog savedAt, re-parse
|
|
646
|
+
const jsonlMtime = stats.mtime.toISOString();
|
|
647
|
+
// Read the session manifest for planRefs and precise mtime check
|
|
648
|
+
const manifest = await readSessionManifest(project.projectPath, sessionId);
|
|
649
|
+
if (catalogSession.savedAt && jsonlMtime > catalogSession.savedAt) {
|
|
650
|
+
if (!manifest || jsonlMtime > manifest.jsonlModifiedAt) {
|
|
651
|
+
uncatalogedFiles.push({
|
|
652
|
+
filePath: jsonlPath,
|
|
653
|
+
projectPath: project.projectPath,
|
|
654
|
+
projectSlug: project.projectSlug,
|
|
655
|
+
});
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// Map subagents from index
|
|
660
|
+
const exploreSubagents = index.subagents.filter((s) => s.sessionId === sessionId && s.type === "exploration");
|
|
661
|
+
const searchSubagents = index.subagents.filter((s) => s.sessionId === sessionId && s.type === "search");
|
|
662
|
+
// Use planRefs from manifest (preserves source: embedded/write/agent)
|
|
663
|
+
// Fall back to reconstructing from PlanEntry if manifest lacks planRefs
|
|
664
|
+
let planRefs = [];
|
|
665
|
+
if (manifest?.planRefs && manifest.planRefs.length > 0) {
|
|
666
|
+
// Manifest has full planRefs with correct source types
|
|
667
|
+
planRefs = manifest.planRefs.map((ref) => {
|
|
668
|
+
// Find matching catalogId from planIds
|
|
669
|
+
const catalogId = catalogSession.planIds?.find((pid) => index.plans.some((p) => p.id === pid));
|
|
670
|
+
return {
|
|
671
|
+
title: ref.title,
|
|
672
|
+
source: ref.source,
|
|
673
|
+
messageIndex: ref.messageIndex,
|
|
674
|
+
filePath: ref.filePath,
|
|
675
|
+
agentId: ref.agentId,
|
|
676
|
+
catalogId: ref.catalogId || catalogId,
|
|
677
|
+
};
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
else if (catalogSession.planIds) {
|
|
681
|
+
// Fallback: reconstruct from PlanEntry (older manifests without planRefs)
|
|
682
|
+
for (const planId of catalogSession.planIds) {
|
|
683
|
+
const plan = index.plans.find((p) => p.id === planId);
|
|
684
|
+
if (plan) {
|
|
685
|
+
planRefs.push(catalogPlanToPlanRef(plan));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const exploreAgents = exploreSubagents.map(catalogSubagentToExploreRef);
|
|
690
|
+
const webSearches = searchSubagents.map(catalogSubagentToSearchRef);
|
|
691
|
+
// Detect git info — probe filesystem first, fall back to JSONL
|
|
692
|
+
const gitInfo = detectGitInfo(project.projectPath);
|
|
693
|
+
if (!gitInfo.branch) {
|
|
694
|
+
gitInfo.branch = await readGitBranchFromJsonl(jsonlPath) || undefined;
|
|
695
|
+
}
|
|
696
|
+
// Build SessionEntry from catalog data + file stats
|
|
697
|
+
const entry = {
|
|
698
|
+
id: sessionId,
|
|
699
|
+
jsonlPath,
|
|
700
|
+
projectPath: project.projectPath,
|
|
701
|
+
projectSlug: project.projectSlug,
|
|
702
|
+
title: catalogSession.title,
|
|
703
|
+
startedAt: catalogSession.startedAt,
|
|
704
|
+
endedAt: catalogSession.endedAt,
|
|
705
|
+
messageCount: catalogSession.messageCount,
|
|
706
|
+
toolCallCount: catalogSession.toolCallCount,
|
|
707
|
+
hasSubagents: catalogSession.hasSubagents ?? false,
|
|
708
|
+
subagentIds: catalogSession.subagentIds,
|
|
709
|
+
hadAutoCompact: catalogSession.hadAutoCompact || undefined,
|
|
710
|
+
tokens: catalogSession.tokens,
|
|
711
|
+
fileSizeBytes: stats.size,
|
|
712
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
713
|
+
mode: catalogSession.mode || undefined,
|
|
714
|
+
planCount: planRefs.length > 0 ? planRefs.length : (catalogSession.planCount || undefined),
|
|
715
|
+
planRefs: planRefs.length > 0 ? planRefs : undefined,
|
|
716
|
+
gitRepoRoot: gitInfo.repoRoot || undefined,
|
|
717
|
+
gitBranch: gitInfo.branch || undefined,
|
|
718
|
+
gitWorktree: gitInfo.worktree || undefined,
|
|
719
|
+
exploreAgents: exploreAgents.length > 0 ? exploreAgents : undefined,
|
|
720
|
+
webSearches: webSearches.length > 0 ? webSearches : undefined,
|
|
721
|
+
};
|
|
722
|
+
catalogSessions.push(entry);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return { catalogSessions, uncatalogedFiles };
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Scan all sessions and build the index.
|
|
729
|
+
*
|
|
730
|
+
* Uses catalog-first loading: reads pre-extracted metadata from .jacques/index.json
|
|
731
|
+
* for each project, only falling back to JSONL parsing for new/uncataloged sessions.
|
|
732
|
+
*/
|
|
733
|
+
export async function buildSessionIndex(options) {
|
|
734
|
+
const { onProgress } = options || {};
|
|
735
|
+
onProgress?.({
|
|
736
|
+
phase: "scanning",
|
|
737
|
+
total: 0,
|
|
738
|
+
completed: 0,
|
|
739
|
+
current: "Scanning projects...",
|
|
740
|
+
});
|
|
741
|
+
// Get all projects
|
|
742
|
+
const projects = await listAllProjects();
|
|
743
|
+
// Phase 1: Read catalog data (fast - reads .jacques/index.json + stats JSONL files)
|
|
744
|
+
const { catalogSessions, uncatalogedFiles } = await buildFromCatalog(projects);
|
|
745
|
+
const totalFiles = catalogSessions.length + uncatalogedFiles.length;
|
|
746
|
+
onProgress?.({
|
|
747
|
+
phase: "processing",
|
|
748
|
+
total: totalFiles,
|
|
749
|
+
completed: catalogSessions.length,
|
|
750
|
+
current: `${catalogSessions.length} from catalog, ${uncatalogedFiles.length} to parse...`,
|
|
751
|
+
});
|
|
752
|
+
// Phase 2: Parse only uncataloged/stale sessions (slow path - only for new sessions)
|
|
753
|
+
const sessions = [...catalogSessions];
|
|
754
|
+
for (let i = 0; i < uncatalogedFiles.length; i++) {
|
|
755
|
+
const file = uncatalogedFiles[i];
|
|
756
|
+
const sessionId = path.basename(file.filePath, ".jsonl");
|
|
757
|
+
onProgress?.({
|
|
758
|
+
phase: "processing",
|
|
759
|
+
total: totalFiles,
|
|
760
|
+
completed: catalogSessions.length + i,
|
|
761
|
+
current: `${file.projectSlug}/${sessionId.substring(0, 8)}...`,
|
|
762
|
+
});
|
|
763
|
+
const metadata = await extractSessionMetadata(file.filePath, file.projectPath, file.projectSlug);
|
|
764
|
+
if (metadata) {
|
|
765
|
+
sessions.push(metadata);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// Sort by modification time (newest first)
|
|
769
|
+
sessions.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime());
|
|
770
|
+
const index = {
|
|
771
|
+
version: "2.0.0",
|
|
772
|
+
lastScanned: new Date().toISOString(),
|
|
773
|
+
sessions,
|
|
774
|
+
};
|
|
775
|
+
// Save to disk
|
|
776
|
+
await writeSessionIndex(index);
|
|
777
|
+
onProgress?.({
|
|
778
|
+
phase: "processing",
|
|
779
|
+
total: totalFiles,
|
|
780
|
+
completed: totalFiles,
|
|
781
|
+
current: "Complete",
|
|
782
|
+
});
|
|
783
|
+
return index;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* In-flight build promise — deduplicates concurrent buildSessionIndex() calls.
|
|
787
|
+
* When multiple callers (e.g. /api/sessions/by-project and /api/projects)
|
|
788
|
+
* request the index simultaneously, only one build runs.
|
|
789
|
+
*/
|
|
790
|
+
let buildInProgress = null;
|
|
791
|
+
/**
|
|
792
|
+
* Get the session index, building if necessary.
|
|
793
|
+
* Concurrent calls that trigger a rebuild share a single build.
|
|
794
|
+
* @param maxAge Maximum age in milliseconds before rebuilding (default: 5 minutes)
|
|
795
|
+
*/
|
|
796
|
+
export async function getSessionIndex(options) {
|
|
797
|
+
const { maxAge = 5 * 60 * 1000 } = options || {};
|
|
798
|
+
const existing = await readSessionIndex();
|
|
799
|
+
// Check if index is fresh enough
|
|
800
|
+
const lastScanned = new Date(existing.lastScanned).getTime();
|
|
801
|
+
const age = Date.now() - lastScanned;
|
|
802
|
+
if (age < maxAge && existing.sessions.length > 0) {
|
|
803
|
+
return existing;
|
|
804
|
+
}
|
|
805
|
+
// Deduplicate concurrent builds
|
|
806
|
+
if (buildInProgress) {
|
|
807
|
+
return buildInProgress;
|
|
808
|
+
}
|
|
809
|
+
buildInProgress = buildSessionIndex().finally(() => {
|
|
810
|
+
buildInProgress = null;
|
|
811
|
+
});
|
|
812
|
+
return buildInProgress;
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Get a single session entry by ID
|
|
816
|
+
*/
|
|
817
|
+
export async function getSessionEntry(sessionId) {
|
|
818
|
+
const index = await getSessionIndex();
|
|
819
|
+
return index.sessions.find((s) => s.id === sessionId) || null;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Get sessions grouped by project.
|
|
823
|
+
* Uses basename of gitRepoRoot when available to group worktrees together.
|
|
824
|
+
*/
|
|
825
|
+
export async function getSessionsByProject() {
|
|
826
|
+
const index = await getSessionIndex();
|
|
827
|
+
const byProject = new Map();
|
|
828
|
+
for (const session of index.sessions) {
|
|
829
|
+
// Group by git repo root basename when available (groups worktrees together)
|
|
830
|
+
const groupKey = session.gitRepoRoot
|
|
831
|
+
? path.basename(session.gitRepoRoot)
|
|
832
|
+
: session.projectSlug;
|
|
833
|
+
const existing = byProject.get(groupKey) || [];
|
|
834
|
+
existing.push(session);
|
|
835
|
+
byProject.set(groupKey, existing);
|
|
836
|
+
}
|
|
837
|
+
return byProject;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Discover all projects from ~/.claude/projects/, grouped by git repo root.
|
|
841
|
+
* Git worktrees of the same repo are merged into a single project entry.
|
|
842
|
+
* Non-git projects are standalone entries.
|
|
843
|
+
*/
|
|
844
|
+
export async function discoverProjects() {
|
|
845
|
+
const rawProjects = await listAllProjects();
|
|
846
|
+
const index = await getSessionIndex();
|
|
847
|
+
// Build a lookup: encoded directory name -> sessions
|
|
848
|
+
// Uses the encoded dir (literal folder name in ~/.claude/projects/) rather than
|
|
849
|
+
// decoded projectPath, because the session index cache may have been built with
|
|
850
|
+
// stale/naive path decoding. The encoded dir name is always stable.
|
|
851
|
+
const sessionsByEncodedDir = new Map();
|
|
852
|
+
for (const session of index.sessions) {
|
|
853
|
+
const encodedDir = path.basename(path.dirname(session.jsonlPath));
|
|
854
|
+
const existing = sessionsByEncodedDir.get(encodedDir) || [];
|
|
855
|
+
existing.push(session);
|
|
856
|
+
sessionsByEncodedDir.set(encodedDir, existing);
|
|
857
|
+
}
|
|
858
|
+
const projectMap = new Map();
|
|
859
|
+
for (const raw of rawProjects) {
|
|
860
|
+
const encodedDir = path.basename(raw.encodedPath);
|
|
861
|
+
const matchingSessions = sessionsByEncodedDir.get(encodedDir) || [];
|
|
862
|
+
// Determine group key and git repo root
|
|
863
|
+
let groupKey;
|
|
864
|
+
let gitRepoRoot = null;
|
|
865
|
+
let isGitProject = false;
|
|
866
|
+
// First: check if any indexed session has gitRepoRoot
|
|
867
|
+
const sessionWithGit = matchingSessions.find((s) => s.gitRepoRoot);
|
|
868
|
+
if (sessionWithGit?.gitRepoRoot) {
|
|
869
|
+
gitRepoRoot = sessionWithGit.gitRepoRoot;
|
|
870
|
+
groupKey = path.basename(gitRepoRoot);
|
|
871
|
+
isGitProject = true;
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
// No git info in index — probe the filesystem
|
|
875
|
+
const gitInfo = detectGitInfo(raw.projectPath);
|
|
876
|
+
if (gitInfo.repoRoot) {
|
|
877
|
+
gitRepoRoot = gitInfo.repoRoot;
|
|
878
|
+
groupKey = path.basename(gitInfo.repoRoot);
|
|
879
|
+
isGitProject = true;
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
// Non-git project: standalone entry
|
|
883
|
+
groupKey = raw.projectSlug;
|
|
884
|
+
isGitProject = false;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
// Find most recent activity among matching sessions
|
|
888
|
+
let latestActivity = null;
|
|
889
|
+
for (const s of matchingSessions) {
|
|
890
|
+
if (s.endedAt && (!latestActivity || s.endedAt > latestActivity)) {
|
|
891
|
+
latestActivity = s.endedAt;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// Merge into existing group or create new
|
|
895
|
+
const existing = projectMap.get(groupKey);
|
|
896
|
+
if (existing) {
|
|
897
|
+
existing.projectPaths.push(raw.projectPath);
|
|
898
|
+
existing.encodedPaths.push(raw.encodedPath);
|
|
899
|
+
existing.sessionCount += matchingSessions.length;
|
|
900
|
+
if (latestActivity && (!existing.lastActivity || latestActivity > existing.lastActivity)) {
|
|
901
|
+
existing.lastActivity = latestActivity;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
projectMap.set(groupKey, {
|
|
906
|
+
name: groupKey,
|
|
907
|
+
gitRepoRoot,
|
|
908
|
+
isGitProject,
|
|
909
|
+
projectPaths: [raw.projectPath],
|
|
910
|
+
encodedPaths: [raw.encodedPath],
|
|
911
|
+
sessionCount: matchingSessions.length,
|
|
912
|
+
lastActivity: latestActivity,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// Second pass: merge non-git projects that have gitBranch into matching git projects.
|
|
917
|
+
// This handles deleted worktrees whose directories no longer exist on disk —
|
|
918
|
+
// detectGitInfo fails but the JSONL sessions still have gitBranch set.
|
|
919
|
+
const nonGitKeys = Array.from(projectMap.entries())
|
|
920
|
+
.filter(([, p]) => !p.isGitProject)
|
|
921
|
+
.map(([key]) => key);
|
|
922
|
+
const gitProjects = Array.from(projectMap.values()).filter((p) => p.isGitProject && p.gitRepoRoot);
|
|
923
|
+
for (const key of nonGitKeys) {
|
|
924
|
+
const project = projectMap.get(key);
|
|
925
|
+
if (!project)
|
|
926
|
+
continue;
|
|
927
|
+
// Check if any session in this project had a git branch
|
|
928
|
+
const allSessions = project.encodedPaths.flatMap((ep) => sessionsByEncodedDir.get(path.basename(ep)) || []);
|
|
929
|
+
const hasGitBranch = allSessions.some((s) => s.gitBranch);
|
|
930
|
+
if (!hasGitBranch)
|
|
931
|
+
continue;
|
|
932
|
+
// Find a git project in the same parent directory
|
|
933
|
+
const projectParent = path.dirname(project.projectPaths[0]);
|
|
934
|
+
const matchingGit = gitProjects.find((gp) => gp.gitRepoRoot && path.dirname(gp.gitRepoRoot) === projectParent);
|
|
935
|
+
if (!matchingGit)
|
|
936
|
+
continue;
|
|
937
|
+
// Merge into the git project
|
|
938
|
+
matchingGit.projectPaths.push(...project.projectPaths);
|
|
939
|
+
matchingGit.encodedPaths.push(...project.encodedPaths);
|
|
940
|
+
matchingGit.sessionCount += project.sessionCount;
|
|
941
|
+
if (project.lastActivity && (!matchingGit.lastActivity || project.lastActivity > matchingGit.lastActivity)) {
|
|
942
|
+
matchingGit.lastActivity = project.lastActivity;
|
|
943
|
+
}
|
|
944
|
+
projectMap.delete(key);
|
|
945
|
+
}
|
|
946
|
+
// Filter out hidden projects
|
|
947
|
+
const hidden = await getHiddenProjects();
|
|
948
|
+
if (hidden.size > 0) {
|
|
949
|
+
for (const [key, project] of projectMap) {
|
|
950
|
+
if (hidden.has(project.name)) {
|
|
951
|
+
projectMap.delete(key);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
// Sort: most recent activity first, then alphabetically
|
|
956
|
+
return Array.from(projectMap.values()).sort((a, b) => {
|
|
957
|
+
if (a.lastActivity && b.lastActivity) {
|
|
958
|
+
return b.lastActivity.localeCompare(a.lastActivity);
|
|
959
|
+
}
|
|
960
|
+
if (a.lastActivity && !b.lastActivity)
|
|
961
|
+
return -1;
|
|
962
|
+
if (!a.lastActivity && b.lastActivity)
|
|
963
|
+
return 1;
|
|
964
|
+
return a.name.localeCompare(b.name);
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
/** Path to hidden projects file */
|
|
968
|
+
const HIDDEN_PROJECTS_FILE = path.join(homedir(), ".jacques", "hidden-projects.json");
|
|
969
|
+
/**
|
|
970
|
+
* Get the set of hidden project names.
|
|
971
|
+
*/
|
|
972
|
+
async function getHiddenProjects() {
|
|
973
|
+
try {
|
|
974
|
+
const content = await fs.readFile(HIDDEN_PROJECTS_FILE, "utf-8");
|
|
975
|
+
const data = JSON.parse(content);
|
|
976
|
+
if (Array.isArray(data)) {
|
|
977
|
+
return new Set(data);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
catch {
|
|
981
|
+
// File doesn't exist or is invalid
|
|
982
|
+
}
|
|
983
|
+
return new Set();
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Hide a project from the discovered list.
|
|
987
|
+
*/
|
|
988
|
+
export async function hideProject(name) {
|
|
989
|
+
const hidden = await getHiddenProjects();
|
|
990
|
+
hidden.add(name);
|
|
991
|
+
await fs.mkdir(path.dirname(HIDDEN_PROJECTS_FILE), { recursive: true });
|
|
992
|
+
await fs.writeFile(HIDDEN_PROJECTS_FILE, JSON.stringify([...hidden], null, 2));
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Unhide a project (restore it to the discovered list).
|
|
996
|
+
*/
|
|
997
|
+
export async function unhideProject(name) {
|
|
998
|
+
const hidden = await getHiddenProjects();
|
|
999
|
+
hidden.delete(name);
|
|
1000
|
+
await fs.writeFile(HIDDEN_PROJECTS_FILE, JSON.stringify([...hidden], null, 2));
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Get index statistics
|
|
1004
|
+
*/
|
|
1005
|
+
export async function getIndexStats() {
|
|
1006
|
+
const index = await getSessionIndex();
|
|
1007
|
+
// Count unique projects
|
|
1008
|
+
const projects = new Set(index.sessions.map((s) => s.projectSlug));
|
|
1009
|
+
// Sum file sizes
|
|
1010
|
+
const totalSize = index.sessions.reduce((sum, s) => sum + s.fileSizeBytes, 0);
|
|
1011
|
+
return {
|
|
1012
|
+
totalSessions: index.sessions.length,
|
|
1013
|
+
totalProjects: projects.size,
|
|
1014
|
+
totalSizeBytes: totalSize,
|
|
1015
|
+
lastScanned: index.lastScanned,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Invalidate the index (force rebuild on next read)
|
|
1020
|
+
*/
|
|
1021
|
+
export async function invalidateIndex() {
|
|
1022
|
+
try {
|
|
1023
|
+
const indexPath = getIndexPath();
|
|
1024
|
+
await fs.unlink(indexPath);
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
// Index doesn't exist, nothing to do
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
//# sourceMappingURL=session-index.js.map
|