@clauderecallhq/cli 0.0.1 → 0.11.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 +33 -0
- package/README.md +543 -3
- package/README.public.md +523 -0
- package/dist/cli.js +354 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/activate.js +69 -0
- package/dist/commands/activate.js.map +1 -0
- package/dist/commands/audit-secrets.js +103 -0
- package/dist/commands/audit-secrets.js.map +1 -0
- package/dist/commands/blame.js +35 -0
- package/dist/commands/blame.js.map +1 -0
- package/dist/commands/config-verification.js +18 -0
- package/dist/commands/config-verification.js.map +1 -0
- package/dist/commands/context.js +144 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/correlate.js +70 -0
- package/dist/commands/correlate.js.map +1 -0
- package/dist/commands/digest.js +78 -0
- package/dist/commands/digest.js.map +1 -0
- package/dist/commands/health.js +62 -0
- package/dist/commands/health.js.map +1 -0
- package/dist/commands/index.js +247 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/install-extension.js +138 -0
- package/dist/commands/install-extension.js.map +1 -0
- package/dist/commands/license.js +39 -0
- package/dist/commands/license.js.map +1 -0
- package/dist/commands/list.js +47 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/mcp.js +29 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/open.js +28 -0
- package/dist/commands/open.js.map +1 -0
- package/dist/commands/paste.js +154 -0
- package/dist/commands/paste.js.map +1 -0
- package/dist/commands/projects.js +36 -0
- package/dist/commands/projects.js.map +1 -0
- package/dist/commands/search.js +67 -0
- package/dist/commands/search.js.map +1 -0
- package/dist/commands/semantic.js +173 -0
- package/dist/commands/semantic.js.map +1 -0
- package/dist/commands/show.js +121 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/start.js +47 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/stats.js +133 -0
- package/dist/commands/stats.js.map +1 -0
- package/dist/commands/status.js +45 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/stop.js +29 -0
- package/dist/commands/stop.js.map +1 -0
- package/dist/commands/thread.js +396 -0
- package/dist/commands/thread.js.map +1 -0
- package/dist/context/formatter.js +103 -0
- package/dist/context/formatter.js.map +1 -0
- package/dist/daemon/auto-tag-config.js +103 -0
- package/dist/daemon/auto-tag-config.js.map +1 -0
- package/dist/daemon/auto-tag-config.test.js +72 -0
- package/dist/daemon/auto-tag-config.test.js.map +1 -0
- package/dist/daemon/auto-title-config.js +70 -0
- package/dist/daemon/auto-title-config.js.map +1 -0
- package/dist/daemon/bulk-title-jobs.js +170 -0
- package/dist/daemon/bulk-title-jobs.js.map +1 -0
- package/dist/daemon/correlator.js +320 -0
- package/dist/daemon/correlator.js.map +1 -0
- package/dist/daemon/discover.js +316 -0
- package/dist/daemon/discover.js.map +1 -0
- package/dist/daemon/editor-detection.js +186 -0
- package/dist/daemon/editor-detection.js.map +1 -0
- package/dist/daemon/entrypoint.js +55 -0
- package/dist/daemon/entrypoint.js.map +1 -0
- package/dist/daemon/git-correlator.js +256 -0
- package/dist/daemon/git-correlator.js.map +1 -0
- package/dist/daemon/mcp-installer.js +108 -0
- package/dist/daemon/mcp-installer.js.map +1 -0
- package/dist/daemon/onboarding-state.js +140 -0
- package/dist/daemon/onboarding-state.js.map +1 -0
- package/dist/daemon/pidfile.js +57 -0
- package/dist/daemon/pidfile.js.map +1 -0
- package/dist/daemon/ports.js +48 -0
- package/dist/daemon/ports.js.map +1 -0
- package/dist/daemon/scanProgressRegistry.js +62 -0
- package/dist/daemon/scanProgressRegistry.js.map +1 -0
- package/dist/daemon/server.js +2010 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/tag-scanner/anthropic-client.js +40 -0
- package/dist/daemon/tag-scanner/anthropic-client.js.map +1 -0
- package/dist/daemon/tag-scanner/autopilot.js +131 -0
- package/dist/daemon/tag-scanner/autopilot.js.map +1 -0
- package/dist/daemon/tag-scanner/claude-cli-driver.js +250 -0
- package/dist/daemon/tag-scanner/claude-cli-driver.js.map +1 -0
- package/dist/daemon/tag-scanner/orchestrator.js +88 -0
- package/dist/daemon/tag-scanner/orchestrator.js.map +1 -0
- package/dist/daemon/tag-scanner/prompt.js +46 -0
- package/dist/daemon/tag-scanner/prompt.js.map +1 -0
- package/dist/daemon/tag-scanner/prompt.test.js +48 -0
- package/dist/daemon/tag-scanner/prompt.test.js.map +1 -0
- package/dist/daemon/tag-scanner/scan-state.js +49 -0
- package/dist/daemon/tag-scanner/scan-state.js.map +1 -0
- package/dist/daemon/tag-scanner/session-fetcher.js +82 -0
- package/dist/daemon/tag-scanner/session-fetcher.js.map +1 -0
- package/dist/daemon/tag-scanner/session-fetcher.test.js +34 -0
- package/dist/daemon/tag-scanner/session-fetcher.test.js.map +1 -0
- package/dist/daemon/tag-scanner/validator.js +50 -0
- package/dist/daemon/tag-scanner/validator.js.map +1 -0
- package/dist/daemon/tag-scanner/validator.test.js +41 -0
- package/dist/daemon/tag-scanner/validator.test.js.map +1 -0
- package/dist/daemon/terminal-registry.js +443 -0
- package/dist/daemon/terminal-registry.js.map +1 -0
- package/dist/daemon/ui.js +64 -0
- package/dist/daemon/ui.js.map +1 -0
- package/dist/daemon/watcher.js +256 -0
- package/dist/daemon/watcher.js.map +1 -0
- package/dist/db/client.js +22 -0
- package/dist/db/client.js.map +1 -0
- package/dist/db/schema.js +496 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/license/api-base.js +13 -0
- package/dist/license/api-base.js.map +1 -0
- package/dist/license/manager.js +43 -0
- package/dist/license/manager.js.map +1 -0
- package/dist/license/public-key.js +19 -0
- package/dist/license/public-key.js.map +1 -0
- package/dist/license/storage.js +27 -0
- package/dist/license/storage.js.map +1 -0
- package/dist/license/verify.js +23 -0
- package/dist/license/verify.js.map +1 -0
- package/dist/mcp/audit.js +126 -0
- package/dist/mcp/audit.js.map +1 -0
- package/dist/mcp/prompts.js +180 -0
- package/dist/mcp/prompts.js.map +1 -0
- package/dist/mcp/server.js +502 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/thread-tools.js +363 -0
- package/dist/mcp/thread-tools.js.map +1 -0
- package/dist/mcp/write-tools.js +239 -0
- package/dist/mcp/write-tools.js.map +1 -0
- package/dist/parser/jsonl.js +150 -0
- package/dist/parser/jsonl.js.map +1 -0
- package/dist/semantic/chunker.js +47 -0
- package/dist/semantic/chunker.js.map +1 -0
- package/dist/semantic/config.js +74 -0
- package/dist/semantic/config.js.map +1 -0
- package/dist/semantic/embedder.js +54 -0
- package/dist/semantic/embedder.js.map +1 -0
- package/dist/semantic/fusion.js +38 -0
- package/dist/semantic/fusion.js.map +1 -0
- package/dist/semantic/model-download.js +69 -0
- package/dist/semantic/model-download.js.map +1 -0
- package/dist/semantic/pipeline.js +375 -0
- package/dist/semantic/pipeline.js.map +1 -0
- package/dist/semantic/query.js +42 -0
- package/dist/semantic/query.js.map +1 -0
- package/dist/semantic/worker.js +78 -0
- package/dist/semantic/worker.js.map +1 -0
- package/dist/stats/backfill.js +151 -0
- package/dist/stats/backfill.js.map +1 -0
- package/dist/stats/health.js +102 -0
- package/dist/stats/health.js.map +1 -0
- package/dist/stats/query.js +385 -0
- package/dist/stats/query.js.map +1 -0
- package/dist/utils/aliases.js +107 -0
- package/dist/utils/aliases.js.map +1 -0
- package/dist/utils/autoCollections.js +635 -0
- package/dist/utils/autoCollections.js.map +1 -0
- package/dist/utils/autoTitle.js +348 -0
- package/dist/utils/autoTitle.js.map +1 -0
- package/dist/utils/collections.js +446 -0
- package/dist/utils/collections.js.map +1 -0
- package/dist/utils/format.js +46 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/notes.js +270 -0
- package/dist/utils/notes.js.map +1 -0
- package/dist/utils/paths.js +50 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/pricing.js +257 -0
- package/dist/utils/pricing.js.map +1 -0
- package/dist/utils/secret-scanner.js +166 -0
- package/dist/utils/secret-scanner.js.map +1 -0
- package/dist/utils/sessionLabel.js +64 -0
- package/dist/utils/sessionLabel.js.map +1 -0
- package/dist/utils/tags.js +97 -0
- package/dist/utils/tags.js.map +1 -0
- package/dist/utils/thread-context.js +129 -0
- package/dist/utils/thread-context.js.map +1 -0
- package/dist/utils/threadFilter.js +18 -0
- package/dist/utils/threadFilter.js.map +1 -0
- package/dist/utils/threads-titler.js +298 -0
- package/dist/utils/threads-titler.js.map +1 -0
- package/dist/utils/threads.js +383 -0
- package/dist/utils/threads.js.map +1 -0
- package/dist/utils/usage.js +76 -0
- package/dist/utils/usage.js.map +1 -0
- package/dist/verification/compute.js +88 -0
- package/dist/verification/compute.js.map +1 -0
- package/dist/verification/config.js +34 -0
- package/dist/verification/config.js.map +1 -0
- package/dist/web/assets/index-CIr6J4Fw.js +1201 -0
- package/dist/web/assets/index-Ctc8g9Jw.css +1 -0
- package/dist/web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- package/dist/web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- package/dist/web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- package/dist/web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- package/dist/web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- package/dist/web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- package/dist/web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
- package/dist/web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
- package/dist/web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- package/dist/web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- package/dist/web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- package/dist/web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- package/dist/web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- package/dist/web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- package/dist/web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
- package/dist/web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
- package/dist/web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- package/dist/web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- package/dist/web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- package/dist/web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- package/dist/web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- package/dist/web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- package/dist/web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
- package/dist/web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
- package/dist/web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- package/dist/web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- package/dist/web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- package/dist/web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- package/dist/web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- package/dist/web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- package/dist/web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
- package/dist/web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
- package/dist/web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist/web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist/web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist/web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist/web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist/web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist/web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist/web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist/web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- package/dist/web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- package/dist/web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- package/dist/web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- package/dist/web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- package/dist/web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- package/dist/web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
- package/dist/web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
- package/dist/web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- package/dist/web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- package/dist/web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- package/dist/web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- package/dist/web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- package/dist/web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- package/dist/web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
- package/dist/web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist/web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
- package/dist/web/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist/web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
- package/dist/web/favicon.svg +9 -0
- package/dist/web/index.html +15 -0
- package/package.json +79 -9
- package/bin/cli.js +0 -12
|
@@ -0,0 +1,2010 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { serve } from '@hono/node-server';
|
|
3
|
+
import { getLicenseStatus } from '../license/manager.js';
|
|
4
|
+
import { serveStatic } from '@hono/node-server/serve-static';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { stat, readFile, realpath } from 'node:fs/promises';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { getDb } from '../db/client.js';
|
|
10
|
+
import { placeholderHtml } from './ui.js';
|
|
11
|
+
import { formatSessionAsContext, } from '../context/formatter.js';
|
|
12
|
+
import { setAlias, clearAlias, getAlias } from '../utils/aliases.js';
|
|
13
|
+
import { deriveAgentNote, getNote, setAutoSynopsis, setNote } from '../utils/notes.js';
|
|
14
|
+
import { addTag, removeTag, tagsForSession, allTagsWithCounts, normalizeTag, } from '../utils/tags.js';
|
|
15
|
+
import { listCollections, getCollection, createCollection, patchCollection, archiveCollection, restoreCollection, addSessionToCollection, removeSessionFromCollection, sessionsInCollection, collectionsForSession, descendantIds, } from '../utils/collections.js';
|
|
16
|
+
import { listRules as listAutoRules, createRule as createAutoRule, patchRule as patchAutoRule, deleteRule as deleteAutoRule, listSuggestions as listAutoSuggestions, acceptSuggestion as acceptAutoSuggestion, dismissSuggestion as dismissAutoSuggestion, detectSuggestions as detectAutoSuggestions, previewMatches as previewAutoSuggestion, autoCollectionIdSet, } from '../utils/autoCollections.js';
|
|
17
|
+
import { createThread, listThreads, getThread, threadsForSession, addSessionToThread, removeSessionFromThread, setParent, renameThread, closeThread, reopenThread, archiveThread, mergeThreads, splitThread, } from '../utils/threads.js';
|
|
18
|
+
import { startBulkTitleJob, subscribeJob, cancelJob, getJobSnapshot, } from './bulk-title-jobs.js';
|
|
19
|
+
import { terminalRegistry, looksLikeClaudeAutoTitle, stripAutoTitlePrefix, } from './terminal-registry.js';
|
|
20
|
+
import { isGenericShellName, tryAutoAlias } from './correlator.js';
|
|
21
|
+
/**
|
|
22
|
+
* Propagate a terminal rename to the alias of every session that started in
|
|
23
|
+
* that shell. Returns the number of sessions whose alias was updated.
|
|
24
|
+
*
|
|
25
|
+
* Name resolution mirrors the correlator's:
|
|
26
|
+
* - Claude Code auto-titles ("⠐ Exploratory coding session", "✳ Claude
|
|
27
|
+
* Code", etc.) get the spinner / busy / version prefix stripped. The
|
|
28
|
+
* clean remainder (e.g. "Exploratory coding session") propagates.
|
|
29
|
+
* - Pure spinner ("✳") or version-only strings ("2.1.119") strip to null
|
|
30
|
+
* and are dropped — no usable content.
|
|
31
|
+
* - Generic shell names ("zsh", "bash", "/bin/zsh") are dropped.
|
|
32
|
+
* - Anything else propagates as-is.
|
|
33
|
+
*
|
|
34
|
+
* Idempotency: if the resolved name equals the session's current alias, we
|
|
35
|
+
* skip the SQLite write. So Claude oscillating its spinner glyph (⠐ ⠂ ⠐ …)
|
|
36
|
+
* produces zero churn — every tick strips to the same name and short-circuits.
|
|
37
|
+
*/
|
|
38
|
+
function propagateRenameToSessions(shell_pid, tab_name) {
|
|
39
|
+
let trimmed = tab_name.trim();
|
|
40
|
+
if (!trimmed)
|
|
41
|
+
return 0;
|
|
42
|
+
if (looksLikeClaudeAutoTitle(trimmed)) {
|
|
43
|
+
const stripped = stripAutoTitlePrefix(trimmed);
|
|
44
|
+
if (!stripped)
|
|
45
|
+
return 0;
|
|
46
|
+
trimmed = stripped;
|
|
47
|
+
}
|
|
48
|
+
if (isGenericShellName(trimmed))
|
|
49
|
+
return 0;
|
|
50
|
+
const sessions = terminalRegistry.sessionsFor(shell_pid);
|
|
51
|
+
let updated = 0;
|
|
52
|
+
for (const sid of sessions) {
|
|
53
|
+
try {
|
|
54
|
+
const current = getAlias(sid);
|
|
55
|
+
if (current === trimmed)
|
|
56
|
+
continue;
|
|
57
|
+
setAlias(sid, trimmed);
|
|
58
|
+
updated++;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* non-fatal */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (updated > 0) {
|
|
65
|
+
// eslint-disable-next-line no-console
|
|
66
|
+
console.log(`[terminal] rename of pid ${shell_pid} → "${trimmed}" propagated to ${updated} session(s)`);
|
|
67
|
+
}
|
|
68
|
+
return updated;
|
|
69
|
+
}
|
|
70
|
+
import { readAutoTagConfig, writeAutoTagConfig, redactForApi, AutoTagConfigSchema, } from './auto-tag-config.js';
|
|
71
|
+
import { readAutoTitleConfig, writeAutoTitleConfig, AutoTitleConfigSchema, } from './auto-title-config.js';
|
|
72
|
+
import { backfillHeuristicTitles, deriveAgentTitle, getAutoTitle, setAutoTitle, } from '../utils/autoTitle.js';
|
|
73
|
+
import { streamSSE } from 'hono/streaming';
|
|
74
|
+
import { z } from 'zod';
|
|
75
|
+
import { listSessionsForScan } from './tag-scanner/session-fetcher.js';
|
|
76
|
+
import { createScan, getScan, subscribe, cancelScan, deleteScan, } from './tag-scanner/scan-state.js';
|
|
77
|
+
import { runScan, applyScanSelection } from './tag-scanner/orchestrator.js';
|
|
78
|
+
import { kickAutopilot, getAutopilotSnapshot, subscribeAutopilot } from './tag-scanner/autopilot.js';
|
|
79
|
+
import { getMcpInstallStatus, installMcp, uninstallMcp } from './mcp-installer.js';
|
|
80
|
+
import { readOnboardingState, writeOnboardingState, resetOnboardingState, OnboardingStateSchema, } from './onboarding-state.js';
|
|
81
|
+
import { isClaudeCliAvailable, runClaudeCliScan, spawnClaudePrompt, } from './tag-scanner/claude-cli-driver.js';
|
|
82
|
+
import { publish as publishScanProgress, subscribe as subscribeScanProgress, } from './scanProgressRegistry.js';
|
|
83
|
+
import { RECALL_PROMPTS, findPrompt } from '../mcp/prompts.js';
|
|
84
|
+
import { readSemanticConfig, writeSemanticConfig, SemanticConfigSchema } from '../semantic/config.js';
|
|
85
|
+
import { backfill as semanticBackfill, getSemanticStatus, processSession as processSemanticSession } from '../semantic/pipeline.js';
|
|
86
|
+
import { getOverviewStats, getProjectStats, getSessionStats, } from '../stats/query.js';
|
|
87
|
+
import { computeAllHealthScores, computeHealthScore } from '../stats/health.js';
|
|
88
|
+
import { getLastBackfillRun, isBackfillRunning, runBackfill, startBackgroundBackfill, } from '../stats/backfill.js';
|
|
89
|
+
import { correlateSession, findSessionsByCommit, getCommitsForSession, } from './git-correlator.js';
|
|
90
|
+
import { discoverToday } from './discover.js';
|
|
91
|
+
import { getEmbedderStatus, loadEmbedder } from '../semantic/embedder.js';
|
|
92
|
+
import { vectorSearch, findSimilarSessions } from '../semantic/query.js';
|
|
93
|
+
import { fuseResults } from '../semantic/fusion.js';
|
|
94
|
+
import { startWorker, getWorkerStatus } from '../semantic/worker.js';
|
|
95
|
+
import { isModelInstalled, downloadModel } from '../semantic/model-download.js';
|
|
96
|
+
import { getOrCompute as getOrComputeVerification } from '../verification/compute.js';
|
|
97
|
+
import { isVerificationEnabled, setVerificationEnabled } from '../verification/config.js';
|
|
98
|
+
const VERSION = '0.11.0';
|
|
99
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
100
|
+
const DIST_WEB = join(here, '..', 'web');
|
|
101
|
+
const INDEX_HTML = join(DIST_WEB, 'index.html');
|
|
102
|
+
const HAS_BUNDLED_UI = existsSync(INDEX_HTML);
|
|
103
|
+
function readStats() {
|
|
104
|
+
const db = getDb();
|
|
105
|
+
return db
|
|
106
|
+
.prepare(`SELECT
|
|
107
|
+
(SELECT COUNT(*) FROM projects) AS projects,
|
|
108
|
+
(SELECT COUNT(*) FROM sessions) AS sessions,
|
|
109
|
+
(SELECT COUNT(*) FROM messages) AS messages,
|
|
110
|
+
(SELECT MIN(started_at) FROM sessions WHERE started_at IS NOT NULL) AS earliest,
|
|
111
|
+
(SELECT MAX(started_at) FROM sessions WHERE started_at IS NOT NULL) AS latest`)
|
|
112
|
+
.get();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Tier-1 localhost hardening. The daemon binds to 127.0.0.1, but a loopback
|
|
116
|
+
* socket alone is not enough — a malicious website the user visits could
|
|
117
|
+
* otherwise reach us via DNS rebinding (attacker domain re-resolves to
|
|
118
|
+
* 127.0.0.1; browser's same-origin policy is bypassed because the hostname
|
|
119
|
+
* "matches"). We defend with three layered checks:
|
|
120
|
+
*
|
|
121
|
+
* 1. Host header must be 127.0.0.1|localhost (optionally with port).
|
|
122
|
+
* DNS-rebinding attacks send the attacker's hostname; we reject those.
|
|
123
|
+
* 2. Origin header, when present, must be a loopback origin. Cross-origin
|
|
124
|
+
* browser requests always carry Origin; non-browser tools (curl, the
|
|
125
|
+
* CLI) omit it and are allowed.
|
|
126
|
+
* 3. Every response carries a strict CSP and related hardening headers so
|
|
127
|
+
* that even if some future bug lets attacker content render, it cannot
|
|
128
|
+
* phone home or execute inline scripts.
|
|
129
|
+
*/
|
|
130
|
+
const HOST_ALLOWED_RE = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
|
|
131
|
+
const ORIGIN_ALLOWED_RE = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
|
|
132
|
+
async function requireProMiddleware(c, next) {
|
|
133
|
+
const status = await getLicenseStatus();
|
|
134
|
+
if (status.tier !== 'pro') {
|
|
135
|
+
return c.json({
|
|
136
|
+
error: 'pro_required',
|
|
137
|
+
message: 'This feature requires a Claude Recall Pro license.',
|
|
138
|
+
upgrade_url: 'https://clauderecall.com/pricing',
|
|
139
|
+
activate_command: 'recall activate <license-key>',
|
|
140
|
+
}, 402);
|
|
141
|
+
}
|
|
142
|
+
await next();
|
|
143
|
+
}
|
|
144
|
+
export function buildApp() {
|
|
145
|
+
const app = new Hono();
|
|
146
|
+
// Layer 1+2: Host and Origin validation. Runs before any route.
|
|
147
|
+
app.use('*', async (c, next) => {
|
|
148
|
+
const host = c.req.raw.headers.get('host') ?? '';
|
|
149
|
+
if (!HOST_ALLOWED_RE.test(host)) {
|
|
150
|
+
return c.text('Forbidden: invalid Host header', 403);
|
|
151
|
+
}
|
|
152
|
+
const origin = c.req.raw.headers.get('origin');
|
|
153
|
+
if (origin && !ORIGIN_ALLOWED_RE.test(origin)) {
|
|
154
|
+
return c.text('Forbidden: cross-origin request rejected', 403);
|
|
155
|
+
}
|
|
156
|
+
await next();
|
|
157
|
+
});
|
|
158
|
+
// Layer 3: security headers on every response.
|
|
159
|
+
app.use('*', async (c, next) => {
|
|
160
|
+
await next();
|
|
161
|
+
// CSP scoped for a local React SPA. 'unsafe-inline' on style is required
|
|
162
|
+
// by Tailwind's injected styles; scripts stay strict.
|
|
163
|
+
c.res.headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
|
|
164
|
+
c.res.headers.set('X-Content-Type-Options', 'nosniff');
|
|
165
|
+
c.res.headers.set('X-Frame-Options', 'DENY');
|
|
166
|
+
c.res.headers.set('Referrer-Policy', 'no-referrer');
|
|
167
|
+
c.res.headers.set('Cross-Origin-Resource-Policy', 'same-origin');
|
|
168
|
+
});
|
|
169
|
+
app.get('/api/health', (c) => c.json({
|
|
170
|
+
status: 'ok',
|
|
171
|
+
version: VERSION,
|
|
172
|
+
pid: process.pid,
|
|
173
|
+
uptimeSeconds: Math.round(process.uptime()),
|
|
174
|
+
}));
|
|
175
|
+
app.get('/api/stats', (c) => c.json(readStats()));
|
|
176
|
+
// v0.10a — cost / token analytics.
|
|
177
|
+
//
|
|
178
|
+
// All three endpoints derive dollar amounts at render time from the
|
|
179
|
+
// static pricing table in src/utils/pricing.ts — only raw token counts
|
|
180
|
+
// are persisted in message_usage + session rollups. Prices change; the
|
|
181
|
+
// source of truth shouldn't.
|
|
182
|
+
app.get('/api/stats/session/:id', (c) => {
|
|
183
|
+
const stats = getSessionStats(c.req.param('id'));
|
|
184
|
+
if (!stats)
|
|
185
|
+
return c.json({ error: 'session not found' }, 404);
|
|
186
|
+
return c.json(stats);
|
|
187
|
+
});
|
|
188
|
+
app.get('/api/stats/project/:name', (c) => {
|
|
189
|
+
const stats = getProjectStats(c.req.param('name'));
|
|
190
|
+
if (!stats)
|
|
191
|
+
return c.json({ error: 'project not found' }, 404);
|
|
192
|
+
return c.json(stats);
|
|
193
|
+
});
|
|
194
|
+
app.get('/api/stats/overview', (c) => {
|
|
195
|
+
const raw = c.req.query('range');
|
|
196
|
+
const range = raw === '7d' ? '7d' : raw === '30d' ? '30d' : 'all';
|
|
197
|
+
return c.json(getOverviewStats(range));
|
|
198
|
+
});
|
|
199
|
+
// Run a backfill pass. Synchronous up to ~5k messages so the UI gets
|
|
200
|
+
// accurate stats back in the same response (chunked txns keep locks
|
|
201
|
+
// short). Larger requests fall back to the background queue.
|
|
202
|
+
app.post('/api/stats/backfill', async (c) => {
|
|
203
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
204
|
+
const limit = body.limit
|
|
205
|
+
? Math.max(1, Math.min(100_000, Number(body.limit)))
|
|
206
|
+
: 5_000;
|
|
207
|
+
if (limit > 5_000) {
|
|
208
|
+
const started = startBackgroundBackfill({ limit });
|
|
209
|
+
return c.json({
|
|
210
|
+
mode: 'background',
|
|
211
|
+
started,
|
|
212
|
+
alreadyRunning: !started && isBackfillRunning(),
|
|
213
|
+
limit,
|
|
214
|
+
lastRun: getLastBackfillRun(),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const result = runBackfill({ limit });
|
|
218
|
+
return c.json({
|
|
219
|
+
mode: 'sync',
|
|
220
|
+
started: false,
|
|
221
|
+
alreadyRunning: false,
|
|
222
|
+
limit,
|
|
223
|
+
result,
|
|
224
|
+
lastRun: getLastBackfillRun(),
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
// v0.16 — Memory health score
|
|
228
|
+
app.get('/api/stats/health', (c) => {
|
|
229
|
+
return c.json(computeAllHealthScores());
|
|
230
|
+
});
|
|
231
|
+
app.get('/api/stats/health/:projectId', (c) => {
|
|
232
|
+
const id = Number(c.req.param('projectId'));
|
|
233
|
+
const result = computeHealthScore(id);
|
|
234
|
+
if (!result)
|
|
235
|
+
return c.json({ error: 'project not found' }, 404);
|
|
236
|
+
return c.json(result);
|
|
237
|
+
});
|
|
238
|
+
// v0.16 — Verification badges
|
|
239
|
+
app.get('/api/config/verification', (c) => {
|
|
240
|
+
return c.json({ enabled: isVerificationEnabled() });
|
|
241
|
+
});
|
|
242
|
+
app.put('/api/config/verification', async (c) => {
|
|
243
|
+
const body = (await c.req.json());
|
|
244
|
+
if (typeof body.enabled === 'boolean') {
|
|
245
|
+
setVerificationEnabled(body.enabled);
|
|
246
|
+
}
|
|
247
|
+
return c.json({ enabled: isVerificationEnabled() });
|
|
248
|
+
});
|
|
249
|
+
app.get('/api/sessions/:id/verification', (c) => {
|
|
250
|
+
const id = c.req.param('id');
|
|
251
|
+
const result = getOrComputeVerification(id);
|
|
252
|
+
return c.json(result);
|
|
253
|
+
});
|
|
254
|
+
// v0.10b — git correlation endpoints.
|
|
255
|
+
//
|
|
256
|
+
// Both are read-only. The write-path (running `git log`) is triggered by
|
|
257
|
+
// the daemon's own watcher + the explicit `recall correlate` CLI; we do
|
|
258
|
+
// not expose a POST here that lets arbitrary HTTP callers fire scoped
|
|
259
|
+
// subprocesses. If a session hasn't been correlated yet, the GET returns
|
|
260
|
+
// an empty list and the transcript header quietly shows nothing.
|
|
261
|
+
app.get('/api/sessions/:id/commits', async (c) => {
|
|
262
|
+
const id = c.req.param('id');
|
|
263
|
+
const existing = getCommitsForSession(id);
|
|
264
|
+
if (existing.length > 0 || c.req.query('refresh') !== '1') {
|
|
265
|
+
return c.json({ commits: existing });
|
|
266
|
+
}
|
|
267
|
+
// Lazy first-view correlation. Only runs when explicitly asked via
|
|
268
|
+
// ?refresh=1 to keep pageload cheap on repeat visits.
|
|
269
|
+
const res = await correlateSession(id);
|
|
270
|
+
return c.json({ commits: getCommitsForSession(id), status: res.status });
|
|
271
|
+
});
|
|
272
|
+
app.get('/api/commits/:sha/session', (c) => {
|
|
273
|
+
const sha = c.req.param('sha');
|
|
274
|
+
if (!/^[0-9a-fA-F]{4,40}$/.test(sha)) {
|
|
275
|
+
return c.json({ error: 'invalid sha format' }, 400);
|
|
276
|
+
}
|
|
277
|
+
return c.json({ sessions: findSessionsByCommit(sha) });
|
|
278
|
+
});
|
|
279
|
+
// v0.12b — Rediscovery "For you" picks. Three independent cards (rediscovered,
|
|
280
|
+
// expensive, authored) plus the availability bitmap so the UI can distinguish
|
|
281
|
+
// "feature off" from "feature on but no pick today". Each card is null when
|
|
282
|
+
// its backing data stream isn't producing yet (v0.11 semantic, v0.10a cost,
|
|
283
|
+
// v0.10b git respectively) — the response shape is stable so the client can
|
|
284
|
+
// render each slot independently.
|
|
285
|
+
app.get('/api/license/status', async (c) => {
|
|
286
|
+
const status = await getLicenseStatus();
|
|
287
|
+
return c.json(status);
|
|
288
|
+
});
|
|
289
|
+
app.get('/api/discover/today', requireProMiddleware, async (c) => {
|
|
290
|
+
try {
|
|
291
|
+
return c.json(await discoverToday());
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
console.error('[discover.today]', err);
|
|
295
|
+
return c.json({
|
|
296
|
+
rediscovered: null,
|
|
297
|
+
expensive: null,
|
|
298
|
+
authored: null,
|
|
299
|
+
availability: { semantic: false, cost: false, git: false },
|
|
300
|
+
generatedAt: new Date().toISOString(),
|
|
301
|
+
error: err.message,
|
|
302
|
+
}, 500);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
app.get('/api/projects', (c) => {
|
|
306
|
+
const db = getDb();
|
|
307
|
+
const rows = db
|
|
308
|
+
.prepare(`SELECT p.id, p.name, p.decoded_path,
|
|
309
|
+
COUNT(s.id) AS session_count,
|
|
310
|
+
COALESCE(SUM(s.message_count), 0) AS message_count,
|
|
311
|
+
MAX(COALESCE(s.ended_at, s.started_at)) AS latest
|
|
312
|
+
FROM projects p
|
|
313
|
+
LEFT JOIN sessions s ON s.project_id = p.id
|
|
314
|
+
GROUP BY p.id
|
|
315
|
+
ORDER BY MAX(COALESCE(s.ended_at, s.started_at, '')) DESC`)
|
|
316
|
+
.all();
|
|
317
|
+
return c.json(rows);
|
|
318
|
+
});
|
|
319
|
+
app.get('/api/sessions', (c) => {
|
|
320
|
+
const db = getDb();
|
|
321
|
+
const project = c.req.query('project');
|
|
322
|
+
const since = c.req.query('since');
|
|
323
|
+
const until = c.req.query('until');
|
|
324
|
+
const tagsFilter = c.req.queries('tag') ?? [];
|
|
325
|
+
const collection = c.req.query('collection');
|
|
326
|
+
const limit = Math.max(1, Math.min(500, Number(c.req.query('limit') ?? 100)));
|
|
327
|
+
const params = { limit };
|
|
328
|
+
let where = 's.message_count > 2';
|
|
329
|
+
if (project) {
|
|
330
|
+
where += ' AND (p.name LIKE @proj OR p.decoded_path LIKE @proj)';
|
|
331
|
+
params.proj = `%${project}%`;
|
|
332
|
+
}
|
|
333
|
+
if (since) {
|
|
334
|
+
where += ' AND s.started_at >= @since';
|
|
335
|
+
params.since = since;
|
|
336
|
+
}
|
|
337
|
+
if (until) {
|
|
338
|
+
where += ' AND s.started_at <= @until';
|
|
339
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(until))
|
|
340
|
+
params.until = `${until}T23:59:59.999Z`;
|
|
341
|
+
else
|
|
342
|
+
params.until = until;
|
|
343
|
+
}
|
|
344
|
+
if (tagsFilter.length > 0) {
|
|
345
|
+
const normalized = tagsFilter.map((t) => normalizeTag(t)).filter(Boolean);
|
|
346
|
+
normalized.forEach((tag, i) => {
|
|
347
|
+
where += ` AND s.id IN (SELECT session_id FROM session_tags WHERE tag = @tag_${i})`;
|
|
348
|
+
params[`tag_${i}`] = tag;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (collection) {
|
|
352
|
+
// Collections are hierarchical; filtering by a parent includes every
|
|
353
|
+
// child collection's sessions too (matches the UX spec: "recursively
|
|
354
|
+
// include child collections when the collection has children").
|
|
355
|
+
const ids = descendantIds(collection);
|
|
356
|
+
if (ids.length === 0) {
|
|
357
|
+
return c.json([]);
|
|
358
|
+
}
|
|
359
|
+
const placeholders = ids.map((_, i) => `@col_${i}`).join(',');
|
|
360
|
+
where += ` AND s.id IN (SELECT session_id FROM collection_sessions WHERE collection_id IN (${placeholders}))`;
|
|
361
|
+
ids.forEach((cid, i) => {
|
|
362
|
+
params[`col_${i}`] = cid;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const rows = db
|
|
366
|
+
.prepare(`SELECT s.id, p.name AS project, s.started_at, s.ended_at,
|
|
367
|
+
s.message_count, s.first_user_message, s.git_branch,
|
|
368
|
+
s.auto_title, s.auto_title_source, s.verification_status,
|
|
369
|
+
NULLIF(sa.alias, '') AS alias,
|
|
370
|
+
CASE
|
|
371
|
+
WHEN (sn.content IS NOT NULL AND sn.content != '')
|
|
372
|
+
OR (sn.auto_synopsis IS NOT NULL AND sn.auto_synopsis != '')
|
|
373
|
+
THEN 1 ELSE 0
|
|
374
|
+
END AS has_notes,
|
|
375
|
+
COALESCE(
|
|
376
|
+
(SELECT GROUP_CONCAT(tag, ',')
|
|
377
|
+
FROM (SELECT tag FROM session_tags WHERE session_id = s.id ORDER BY tag)),
|
|
378
|
+
''
|
|
379
|
+
) AS tags_csv
|
|
380
|
+
FROM sessions s
|
|
381
|
+
JOIN projects p ON p.id = s.project_id
|
|
382
|
+
LEFT JOIN session_aliases sa ON sa.session_id = s.id
|
|
383
|
+
LEFT JOIN session_notes sn ON sn.session_id = s.id
|
|
384
|
+
WHERE ${where}
|
|
385
|
+
ORDER BY COALESCE(s.ended_at, s.started_at, '') DESC
|
|
386
|
+
LIMIT @limit`)
|
|
387
|
+
.all(params);
|
|
388
|
+
// Expand tags_csv into a real array for the client, then decorate each
|
|
389
|
+
// row with the editor/terminal origin (in-memory, v0.15 T4). Origin is
|
|
390
|
+
// a separate lookup because we deliberately don't persist it to SQLite.
|
|
391
|
+
const expanded = rows.map(({ tags_csv, ...rest }) => {
|
|
392
|
+
const sessionId = rest.id;
|
|
393
|
+
const detected = terminalRegistry.getOrigin(sessionId);
|
|
394
|
+
// 'auto' = correlator-set from a terminal tab name or origin fallback.
|
|
395
|
+
// 'manual' = user typed it via PUT /api/sessions/:id/alias. Mirrors
|
|
396
|
+
// the same logic in the detail endpoint so the list view can apply
|
|
397
|
+
// the same display precedence (auto_title beats auto-alias).
|
|
398
|
+
const aliasValue = rest.alias;
|
|
399
|
+
const aliasSource = aliasValue == null
|
|
400
|
+
? null
|
|
401
|
+
: terminalRegistry.isSessionAutoLinked(sessionId)
|
|
402
|
+
? 'auto'
|
|
403
|
+
: 'manual';
|
|
404
|
+
return {
|
|
405
|
+
...rest,
|
|
406
|
+
tags: tags_csv ? tags_csv.split(',') : [],
|
|
407
|
+
origin: detected ? { editor: detected.editor, label: detected.label } : null,
|
|
408
|
+
alias_source: aliasSource,
|
|
409
|
+
};
|
|
410
|
+
});
|
|
411
|
+
return c.json(expanded);
|
|
412
|
+
});
|
|
413
|
+
app.get('/api/sessions/:id', (c) => {
|
|
414
|
+
const db = getDb();
|
|
415
|
+
const id = c.req.param('id');
|
|
416
|
+
const session = db
|
|
417
|
+
.prepare(`SELECT s.*, p.name AS project_name, p.decoded_path,
|
|
418
|
+
NULLIF(sa.alias, '') AS alias
|
|
419
|
+
FROM sessions s
|
|
420
|
+
JOIN projects p ON p.id = s.project_id
|
|
421
|
+
LEFT JOIN session_aliases sa ON sa.session_id = s.id
|
|
422
|
+
WHERE s.id = ?`)
|
|
423
|
+
.get(id);
|
|
424
|
+
if (!session)
|
|
425
|
+
return c.json({ error: 'not found' }, 404);
|
|
426
|
+
const tags = tagsForSession(id);
|
|
427
|
+
const detected = terminalRegistry.getOrigin(id);
|
|
428
|
+
const origin = detected
|
|
429
|
+
? { editor: detected.editor, label: detected.label }
|
|
430
|
+
: null;
|
|
431
|
+
// 'auto' = alias set by the correlator from a terminal tab name or origin
|
|
432
|
+
// fallback. 'manual' = user typed it via PUT /api/sessions/:id/alias.
|
|
433
|
+
// The client uses this to decide whether an ✨ agent title should beat
|
|
434
|
+
// the alias on display (auto → yes, manual → no).
|
|
435
|
+
const aliasSource = session.alias == null
|
|
436
|
+
? null
|
|
437
|
+
: terminalRegistry.isSessionAutoLinked(id)
|
|
438
|
+
? 'auto'
|
|
439
|
+
: 'manual';
|
|
440
|
+
const messages = db
|
|
441
|
+
.prepare(`SELECT uuid, type, role, timestamp, is_sidechain, content_text, tool_names
|
|
442
|
+
FROM messages
|
|
443
|
+
WHERE session_id = ?
|
|
444
|
+
ORDER BY COALESCE(timestamp, ''), rowid`)
|
|
445
|
+
.all(id);
|
|
446
|
+
return c.json({
|
|
447
|
+
session: { ...session, tags, origin, alias_source: aliasSource },
|
|
448
|
+
messages,
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
// Tag endpoints
|
|
452
|
+
app.get('/api/tags', (c) => c.json(allTagsWithCounts()));
|
|
453
|
+
app.get('/api/sessions/:id/tags', (c) => {
|
|
454
|
+
return c.json({ tags: tagsForSession(c.req.param('id')) });
|
|
455
|
+
});
|
|
456
|
+
app.post('/api/sessions/:id/tags', async (c) => {
|
|
457
|
+
const id = c.req.param('id');
|
|
458
|
+
const body = (await c.req.json().catch(() => null));
|
|
459
|
+
if (!body || typeof body.tag !== 'string') {
|
|
460
|
+
return c.json({ error: 'tag required' }, 400);
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
const result = addTag(id, body.tag);
|
|
464
|
+
return c.json(result);
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
return c.json({ error: err.message }, 400);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
app.delete('/api/sessions/:id/tags/:tag', (c) => {
|
|
471
|
+
const id = c.req.param('id');
|
|
472
|
+
const tag = c.req.param('tag');
|
|
473
|
+
return c.json(removeTag(id, tag));
|
|
474
|
+
});
|
|
475
|
+
// Auto-tag config. GET redacts the apiKey over the wire (returns a marker
|
|
476
|
+
// + `hasApiKey` bool). PUT merges the submitted patch with existing values,
|
|
477
|
+
// preserving a stored apiKey when the PUT body omits one.
|
|
478
|
+
app.get('/api/config/auto-tag', (c) => {
|
|
479
|
+
return c.json(redactForApi(readAutoTagConfig()));
|
|
480
|
+
});
|
|
481
|
+
app.put('/api/config/auto-tag', async (c) => {
|
|
482
|
+
const body = await c.req.json().catch(() => ({}));
|
|
483
|
+
const parsed = AutoTagConfigSchema.partial().safeParse(body);
|
|
484
|
+
if (!parsed.success) {
|
|
485
|
+
return c.json({ error: 'invalid config', issues: parsed.error.issues }, 400);
|
|
486
|
+
}
|
|
487
|
+
const patch = parsed.data;
|
|
488
|
+
// If caller did not send an apiKey field, preserve the existing one.
|
|
489
|
+
if (patch.apiKey === undefined) {
|
|
490
|
+
delete patch.apiKey;
|
|
491
|
+
}
|
|
492
|
+
const merged = writeAutoTagConfig(patch);
|
|
493
|
+
// Autopilot depends on config — kick it so it picks up flag changes.
|
|
494
|
+
if (merged.autopilot && merged.enabled && merged.backend === 'api' && merged.apiKey) {
|
|
495
|
+
void kickAutopilot();
|
|
496
|
+
}
|
|
497
|
+
return c.json(redactForApi(merged));
|
|
498
|
+
});
|
|
499
|
+
// v0.14a — first-run onboarding state. The web UI hits this on boot; if
|
|
500
|
+
// `completed` and `skipped` are both false the 3-step tour fires. Writes
|
|
501
|
+
// are additive (history-preserving); reset wipes the terminal flags so
|
|
502
|
+
// the "Replay onboarding" button in the Command Center can fire again.
|
|
503
|
+
app.get('/api/onboarding', (c) => {
|
|
504
|
+
const db = getDb();
|
|
505
|
+
const latest = db
|
|
506
|
+
.prepare(`SELECT s.id,
|
|
507
|
+
p.name AS project,
|
|
508
|
+
s.started_at,
|
|
509
|
+
s.ended_at,
|
|
510
|
+
s.message_count,
|
|
511
|
+
s.first_user_message,
|
|
512
|
+
NULLIF(sa.alias, '') AS alias
|
|
513
|
+
FROM sessions s
|
|
514
|
+
JOIN projects p ON p.id = s.project_id
|
|
515
|
+
LEFT JOIN session_aliases sa ON sa.session_id = s.id
|
|
516
|
+
WHERE s.message_count > 2
|
|
517
|
+
ORDER BY COALESCE(s.ended_at, s.started_at, '') DESC
|
|
518
|
+
LIMIT 1`)
|
|
519
|
+
.get();
|
|
520
|
+
return c.json({ state: readOnboardingState(), mostRecentSession: latest ?? null });
|
|
521
|
+
});
|
|
522
|
+
app.put('/api/onboarding', async (c) => {
|
|
523
|
+
const body = await c.req.json().catch(() => ({}));
|
|
524
|
+
const parsed = OnboardingStateSchema.partial().safeParse(body);
|
|
525
|
+
if (!parsed.success) {
|
|
526
|
+
return c.json({ error: 'invalid onboarding state', issues: parsed.error.issues }, 400);
|
|
527
|
+
}
|
|
528
|
+
return c.json(writeOnboardingState(parsed.data));
|
|
529
|
+
});
|
|
530
|
+
app.post('/api/onboarding/reset', (c) => c.json(resetOnboardingState()));
|
|
531
|
+
// One-click MCP install into ~/.claude.json. Merges surgically; never
|
|
532
|
+
// clobbers other MCP entries the user may already have.
|
|
533
|
+
app.get('/api/config/mcp-install', (c) => c.json({ ...getMcpInstallStatus(), claudeCliAvailable: isClaudeCliAvailable() }));
|
|
534
|
+
app.post('/api/config/mcp-install', (c) => c.json({ ...installMcp(), claudeCliAvailable: isClaudeCliAvailable() }));
|
|
535
|
+
app.delete('/api/config/mcp-install', (c) => c.json({ ...uninstallMcp(), claudeCliAvailable: isClaudeCliAvailable() }));
|
|
536
|
+
// Button-driven MCP scan: spawn `claude -p` in headless mode so the
|
|
537
|
+
// user's own Claude Code subscription does the work — no API key, no
|
|
538
|
+
// copy-paste. Claude picks up the Recall MCP server from ~/.claude.json
|
|
539
|
+
// and applies tags via mcp__recall__apply_tags. We block until the
|
|
540
|
+
// subprocess finishes (capped at 30 min by the driver) and report the
|
|
541
|
+
// tag-count delta so the UI can show a concrete result.
|
|
542
|
+
const ClaudeCliScanSchema = z.object({
|
|
543
|
+
scope: z
|
|
544
|
+
.object({
|
|
545
|
+
untaggedOnly: z.boolean().optional(),
|
|
546
|
+
project: z.string().optional(),
|
|
547
|
+
collectionId: z.string().optional(),
|
|
548
|
+
sessionIds: z.array(z.string()).optional(),
|
|
549
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
550
|
+
})
|
|
551
|
+
.default({}),
|
|
552
|
+
/** Optional per-run model override; falls back to config.model, then claude's default. */
|
|
553
|
+
model: z.string().optional(),
|
|
554
|
+
/**
|
|
555
|
+
* Optional client-generated scan id. When present the runner emits per-
|
|
556
|
+
* session progress events on scanProgressRegistry so an EventSource
|
|
557
|
+
* subscriber can render live "Tagging session X of N" feedback. Clients
|
|
558
|
+
* that don't care (e.g. the legacy settings dialog) can omit it.
|
|
559
|
+
*/
|
|
560
|
+
scanId: z.string().min(1).max(100).optional(),
|
|
561
|
+
});
|
|
562
|
+
app.post('/api/tags/scan/claude-cli', async (c) => {
|
|
563
|
+
if (!isClaudeCliAvailable()) {
|
|
564
|
+
return c.json({
|
|
565
|
+
error: 'claude CLI not found on PATH. Install Claude Code locally, then reload.',
|
|
566
|
+
}, 400);
|
|
567
|
+
}
|
|
568
|
+
const mcp = getMcpInstallStatus();
|
|
569
|
+
if (!mcp.installed) {
|
|
570
|
+
return c.json({ error: 'Recall MCP is not installed in Claude Code yet — run the one-click install first.' }, 400);
|
|
571
|
+
}
|
|
572
|
+
const body = await c.req.json().catch(() => ({}));
|
|
573
|
+
const parsed = ClaudeCliScanSchema.safeParse(body);
|
|
574
|
+
if (!parsed.success) {
|
|
575
|
+
return c.json({ error: 'invalid scope', issues: parsed.error.issues }, 400);
|
|
576
|
+
}
|
|
577
|
+
const scope = parsed.data.scope;
|
|
578
|
+
// Model precedence: per-run override → config.model → claude's default.
|
|
579
|
+
const cfg = readAutoTagConfig();
|
|
580
|
+
const model = parsed.data.model ?? cfg.model;
|
|
581
|
+
const db = getDb();
|
|
582
|
+
const tagCount = () => db
|
|
583
|
+
.prepare('SELECT COUNT(*) AS n FROM session_tags')
|
|
584
|
+
.get().n;
|
|
585
|
+
const before = tagCount();
|
|
586
|
+
const scanId = parsed.data.scanId;
|
|
587
|
+
const result = await runClaudeCliScan(scope, { model, scanId });
|
|
588
|
+
const after = tagCount();
|
|
589
|
+
const tagsAdded = Math.max(0, after - before);
|
|
590
|
+
// When the client passed a scanId, broadcast the terminal event so any
|
|
591
|
+
// open SSE subscriber can render the final success bubble and close.
|
|
592
|
+
if (scanId) {
|
|
593
|
+
publishScanProgress(scanId, {
|
|
594
|
+
type: 'done',
|
|
595
|
+
result: { success: result.success, exitCode: result.exitCode, tagsAdded },
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
return c.json({
|
|
599
|
+
success: result.success,
|
|
600
|
+
exitCode: result.exitCode,
|
|
601
|
+
tagsAdded,
|
|
602
|
+
model,
|
|
603
|
+
stdout: result.stdout.slice(0, 2000),
|
|
604
|
+
stderrTail: result.stderr.slice(-2000),
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
// Per-scan progress stream for the claude-cli auto-tag runner. Clients
|
|
608
|
+
// generate a scanId (UUID), open this EventSource first, then POST
|
|
609
|
+
// /api/tags/scan/claude-cli with the same id — the POST runs the scan
|
|
610
|
+
// and publishes progress on the registry while this route relays it.
|
|
611
|
+
// Heartbeats every 15s to keep intermediaries from closing the socket.
|
|
612
|
+
app.get('/api/claude-cli/scan/:scanId/progress', (c) => {
|
|
613
|
+
const scanId = c.req.param('scanId');
|
|
614
|
+
return streamSSE(c, async (stream) => {
|
|
615
|
+
const pending = [];
|
|
616
|
+
const wake = { resolve: () => { } };
|
|
617
|
+
let waiter = new Promise((r) => {
|
|
618
|
+
wake.resolve = r;
|
|
619
|
+
});
|
|
620
|
+
const unsubscribe = subscribeScanProgress(scanId, (ev) => {
|
|
621
|
+
pending.push(ev);
|
|
622
|
+
const prev = wake.resolve;
|
|
623
|
+
waiter = new Promise((r) => {
|
|
624
|
+
wake.resolve = r;
|
|
625
|
+
});
|
|
626
|
+
prev();
|
|
627
|
+
});
|
|
628
|
+
// Heartbeat loop: every 15s, push a `: heartbeat` comment. Keeps
|
|
629
|
+
// proxies (if any) and browsers from considering the stream idle.
|
|
630
|
+
let closed = false;
|
|
631
|
+
const heartbeat = setInterval(() => {
|
|
632
|
+
if (closed)
|
|
633
|
+
return;
|
|
634
|
+
stream.writeSSE({ event: 'heartbeat', data: '' }).catch(() => {
|
|
635
|
+
closed = true;
|
|
636
|
+
});
|
|
637
|
+
}, 15_000);
|
|
638
|
+
try {
|
|
639
|
+
while (!closed) {
|
|
640
|
+
if (pending.length === 0)
|
|
641
|
+
await waiter;
|
|
642
|
+
const ev = pending.shift();
|
|
643
|
+
if (!ev)
|
|
644
|
+
continue;
|
|
645
|
+
await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev) });
|
|
646
|
+
if (ev.type === 'done')
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
closed = true;
|
|
652
|
+
clearInterval(heartbeat);
|
|
653
|
+
unsubscribe();
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
// Recall prompt library: list + run. Any Recall prompt registered in
|
|
658
|
+
// src/mcp/prompts.ts is automatically exposed here so the web UI (and
|
|
659
|
+
// any other HTTP consumer) can spawn the user's local claude CLI to
|
|
660
|
+
// execute it. Same prompts, same MCP tools, same one-source-of-truth.
|
|
661
|
+
app.get('/api/prompts', (c) => c.json({
|
|
662
|
+
prompts: RECALL_PROMPTS.map((p) => ({
|
|
663
|
+
name: p.name,
|
|
664
|
+
title: p.title,
|
|
665
|
+
description: p.description,
|
|
666
|
+
})),
|
|
667
|
+
claudeCliAvailable: isClaudeCliAvailable(),
|
|
668
|
+
}));
|
|
669
|
+
app.post('/api/prompts/run', async (c) => {
|
|
670
|
+
if (!isClaudeCliAvailable()) {
|
|
671
|
+
return c.json({ error: 'claude CLI not found on PATH. Install Claude Code locally, then reload.' }, 400);
|
|
672
|
+
}
|
|
673
|
+
const mcp = getMcpInstallStatus();
|
|
674
|
+
if (!mcp.installed) {
|
|
675
|
+
return c.json({ error: 'Recall MCP is not installed in Claude Code yet — run the one-click install first.' }, 400);
|
|
676
|
+
}
|
|
677
|
+
const body = await c.req.json().catch(() => ({}));
|
|
678
|
+
const PromptRunSchema = z.object({
|
|
679
|
+
name: z.string(),
|
|
680
|
+
args: z.record(z.string(), z.unknown()).optional(),
|
|
681
|
+
model: z.string().optional(),
|
|
682
|
+
});
|
|
683
|
+
const parsed = PromptRunSchema.safeParse(body);
|
|
684
|
+
if (!parsed.success) {
|
|
685
|
+
return c.json({ error: 'invalid request', issues: parsed.error.issues }, 400);
|
|
686
|
+
}
|
|
687
|
+
const def = findPrompt(parsed.data.name);
|
|
688
|
+
if (!def)
|
|
689
|
+
return c.json({ error: `unknown prompt: ${parsed.data.name}` }, 404);
|
|
690
|
+
const promptText = def.build((parsed.data.args ?? {}));
|
|
691
|
+
const cfg = readAutoTagConfig();
|
|
692
|
+
const model = parsed.data.model ?? cfg.model;
|
|
693
|
+
const result = await spawnClaudePrompt(promptText, def.allowedTools, { model });
|
|
694
|
+
return c.json({
|
|
695
|
+
success: result.success,
|
|
696
|
+
exitCode: result.exitCode,
|
|
697
|
+
promptName: def.name,
|
|
698
|
+
model,
|
|
699
|
+
stdout: result.stdout,
|
|
700
|
+
stderrTail: result.stderr.slice(-4000),
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
// Autopilot status: GET snapshot, SSE stream for live progress updates.
|
|
704
|
+
app.get('/api/autopilot/status', (c) => c.json(getAutopilotSnapshot()));
|
|
705
|
+
app.get('/api/autopilot/events', (c) => streamSSE(c, async (stream) => {
|
|
706
|
+
await stream.writeSSE({ event: 'state', data: JSON.stringify(getAutopilotSnapshot()) });
|
|
707
|
+
const pending = [];
|
|
708
|
+
let resolve = () => { };
|
|
709
|
+
let wait = new Promise((r) => (resolve = r));
|
|
710
|
+
const unsubscribe = subscribeAutopilot((snap) => {
|
|
711
|
+
pending.push(snap);
|
|
712
|
+
const prev = resolve;
|
|
713
|
+
wait = new Promise((r) => (resolve = r));
|
|
714
|
+
prev();
|
|
715
|
+
});
|
|
716
|
+
try {
|
|
717
|
+
// Heartbeat loop with 30s max idle to keep the stream warm.
|
|
718
|
+
while (true) {
|
|
719
|
+
if (pending.length === 0) {
|
|
720
|
+
const heartbeat = new Promise((r) => setTimeout(() => r('tick'), 30000));
|
|
721
|
+
const next = await Promise.race([wait.then(() => 'event'), heartbeat]);
|
|
722
|
+
if (next === 'tick') {
|
|
723
|
+
await stream.writeSSE({ event: 'heartbeat', data: '1' });
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const snap = pending.shift();
|
|
728
|
+
if (!snap)
|
|
729
|
+
continue;
|
|
730
|
+
await stream.writeSSE({ event: 'state', data: JSON.stringify(snap) });
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
finally {
|
|
734
|
+
unsubscribe();
|
|
735
|
+
}
|
|
736
|
+
}));
|
|
737
|
+
app.post('/api/autopilot/kick', (c) => {
|
|
738
|
+
void kickAutopilot();
|
|
739
|
+
return c.json({ ok: true, snapshot: getAutopilotSnapshot() });
|
|
740
|
+
});
|
|
741
|
+
// Auto-tag scan lifecycle.
|
|
742
|
+
// POST /api/tags/scan — start a background scan, returns { scanId, total }
|
|
743
|
+
// GET /api/tags/scan/:id — snapshot of current state
|
|
744
|
+
// GET /api/tags/scan/:id/events — SSE stream of progress/result/status/done events
|
|
745
|
+
// POST /api/tags/scan/:id/apply — persist selected suggestions as tags
|
|
746
|
+
// DELETE /api/tags/scan/:id — cancel + forget
|
|
747
|
+
const StartScanSchema = z.object({
|
|
748
|
+
scope: z
|
|
749
|
+
.object({
|
|
750
|
+
untaggedOnly: z.boolean().optional(),
|
|
751
|
+
project: z.string().optional(),
|
|
752
|
+
collectionId: z.string().optional(),
|
|
753
|
+
sessionIds: z.array(z.string()).optional(),
|
|
754
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
755
|
+
})
|
|
756
|
+
.default({}),
|
|
757
|
+
});
|
|
758
|
+
app.post('/api/tags/scan', async (c) => {
|
|
759
|
+
const cfg = readAutoTagConfig();
|
|
760
|
+
if (!cfg.enabled)
|
|
761
|
+
return c.json({ error: 'auto-tagging is disabled' }, 403);
|
|
762
|
+
if (cfg.backend !== 'api') {
|
|
763
|
+
return c.json({ error: 'api-backend scan requires backend=api in config' }, 400);
|
|
764
|
+
}
|
|
765
|
+
if (!cfg.apiKey)
|
|
766
|
+
return c.json({ error: 'no api key configured' }, 400);
|
|
767
|
+
const body = await c.req.json().catch(() => ({}));
|
|
768
|
+
const parsed = StartScanSchema.safeParse(body);
|
|
769
|
+
if (!parsed.success)
|
|
770
|
+
return c.json({ error: 'invalid scope', issues: parsed.error.issues }, 400);
|
|
771
|
+
const sessions = listSessionsForScan(parsed.data.scope);
|
|
772
|
+
if (sessions.length === 0)
|
|
773
|
+
return c.json({ error: 'no sessions match scope' }, 400);
|
|
774
|
+
const rec = createScan(sessions.length);
|
|
775
|
+
// fire-and-forget; client subscribes via SSE
|
|
776
|
+
void runScan(rec, {
|
|
777
|
+
apiKey: cfg.apiKey,
|
|
778
|
+
model: cfg.model,
|
|
779
|
+
minTags: cfg.minTagsPerSession,
|
|
780
|
+
maxTags: cfg.maxTagsPerSession,
|
|
781
|
+
sessions,
|
|
782
|
+
});
|
|
783
|
+
return c.json({ scanId: rec.id, total: rec.total });
|
|
784
|
+
});
|
|
785
|
+
app.get('/api/tags/scan/:id', (c) => {
|
|
786
|
+
const rec = getScan(c.req.param('id'));
|
|
787
|
+
if (!rec)
|
|
788
|
+
return c.json({ error: 'scan not found' }, 404);
|
|
789
|
+
// Strip non-serializable fields (AbortController, listener set) before
|
|
790
|
+
// emitting a snapshot over the wire.
|
|
791
|
+
const { controller: _controller, listeners: _listeners, ...safe } = rec;
|
|
792
|
+
void _controller;
|
|
793
|
+
void _listeners;
|
|
794
|
+
return c.json(safe);
|
|
795
|
+
});
|
|
796
|
+
app.get('/api/tags/scan/:id/events', (c) => {
|
|
797
|
+
const rec = getScan(c.req.param('id'));
|
|
798
|
+
if (!rec)
|
|
799
|
+
return c.json({ error: 'scan not found' }, 404);
|
|
800
|
+
return streamSSE(c, async (stream) => {
|
|
801
|
+
// Replay current state so late subscribers see what they missed.
|
|
802
|
+
await stream.writeSSE({
|
|
803
|
+
event: 'state',
|
|
804
|
+
data: JSON.stringify({
|
|
805
|
+
completed: rec.completed,
|
|
806
|
+
total: rec.total,
|
|
807
|
+
status: rec.status,
|
|
808
|
+
}),
|
|
809
|
+
});
|
|
810
|
+
for (const r of rec.results) {
|
|
811
|
+
await stream.writeSSE({ event: 'result', data: JSON.stringify(r) });
|
|
812
|
+
}
|
|
813
|
+
// Subscribe for new events via a simple pending-queue + wake-promise.
|
|
814
|
+
const pending = [];
|
|
815
|
+
const wake = { resolve: () => { } };
|
|
816
|
+
let waiter = new Promise((r) => {
|
|
817
|
+
wake.resolve = r;
|
|
818
|
+
});
|
|
819
|
+
const unsubscribe = subscribe(rec, (ev) => {
|
|
820
|
+
pending.push(ev);
|
|
821
|
+
const prev = wake.resolve;
|
|
822
|
+
waiter = new Promise((r) => {
|
|
823
|
+
wake.resolve = r;
|
|
824
|
+
});
|
|
825
|
+
prev();
|
|
826
|
+
});
|
|
827
|
+
try {
|
|
828
|
+
while (rec.status === 'running' || rec.status === 'pending') {
|
|
829
|
+
if (pending.length === 0)
|
|
830
|
+
await waiter;
|
|
831
|
+
const ev = pending.shift();
|
|
832
|
+
if (!ev)
|
|
833
|
+
continue;
|
|
834
|
+
await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev) });
|
|
835
|
+
if (ev.type === 'done')
|
|
836
|
+
break;
|
|
837
|
+
if (ev.type === 'status' && (ev.status === 'cancelled' || ev.status === 'failed'))
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
finally {
|
|
842
|
+
unsubscribe();
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
const ApplySchema = z.object({
|
|
847
|
+
selection: z.array(z.object({
|
|
848
|
+
sessionId: z.string(),
|
|
849
|
+
tags: z.array(z.string()).min(1),
|
|
850
|
+
})),
|
|
851
|
+
});
|
|
852
|
+
app.post('/api/tags/scan/:id/apply', async (c) => {
|
|
853
|
+
const rec = getScan(c.req.param('id'));
|
|
854
|
+
if (!rec)
|
|
855
|
+
return c.json({ error: 'scan not found' }, 404);
|
|
856
|
+
const body = await c.req.json().catch(() => ({}));
|
|
857
|
+
const parsed = ApplySchema.safeParse(body);
|
|
858
|
+
if (!parsed.success)
|
|
859
|
+
return c.json({ error: 'invalid selection' }, 400);
|
|
860
|
+
const result = applyScanSelection(rec, parsed.data.selection);
|
|
861
|
+
return c.json(result);
|
|
862
|
+
});
|
|
863
|
+
app.delete('/api/tags/scan/:id', (c) => {
|
|
864
|
+
const id = c.req.param('id');
|
|
865
|
+
cancelScan(id);
|
|
866
|
+
deleteScan(id);
|
|
867
|
+
return c.json({ ok: true });
|
|
868
|
+
});
|
|
869
|
+
// Alias CRUD. Never deletes history — DELETE clears to empty with history preserved.
|
|
870
|
+
app.put('/api/sessions/:id/alias', async (c) => {
|
|
871
|
+
const id = c.req.param('id');
|
|
872
|
+
const body = (await c.req.json().catch(() => null));
|
|
873
|
+
if (!body || typeof body.alias !== 'string') {
|
|
874
|
+
return c.json({ error: 'alias required' }, 400);
|
|
875
|
+
}
|
|
876
|
+
try {
|
|
877
|
+
const row = setAlias(id, body.alias);
|
|
878
|
+
// User explicitly typed an alias — from now on treat it as manual
|
|
879
|
+
// intent. The correlator MUST NOT clobber it on rename, and the client
|
|
880
|
+
// MUST render it over any agent title.
|
|
881
|
+
terminalRegistry.unlinkSession(id);
|
|
882
|
+
return c.json(row);
|
|
883
|
+
}
|
|
884
|
+
catch (err) {
|
|
885
|
+
return c.json({ error: err.message }, 400);
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
app.delete('/api/sessions/:id/alias', (c) => {
|
|
889
|
+
const id = c.req.param('id');
|
|
890
|
+
clearAlias(id);
|
|
891
|
+
terminalRegistry.unlinkSession(id);
|
|
892
|
+
return c.json({ ok: true });
|
|
893
|
+
});
|
|
894
|
+
app.get('/api/sessions/:id/alias', (c) => {
|
|
895
|
+
const id = c.req.param('id');
|
|
896
|
+
return c.json({ alias: getAlias(id) });
|
|
897
|
+
});
|
|
898
|
+
// v0.14b (T5) — auto-title config.
|
|
899
|
+
app.get('/api/config/auto-title', (c) => c.json(readAutoTitleConfig()));
|
|
900
|
+
app.put('/api/config/auto-title', async (c) => {
|
|
901
|
+
const body = await c.req.json().catch(() => ({}));
|
|
902
|
+
const parsed = AutoTitleConfigSchema.partial().safeParse(body);
|
|
903
|
+
if (!parsed.success) {
|
|
904
|
+
return c.json({ error: 'invalid config', issues: parsed.error.issues }, 400);
|
|
905
|
+
}
|
|
906
|
+
return c.json(writeAutoTitleConfig(parsed.data));
|
|
907
|
+
});
|
|
908
|
+
app.get('/api/sessions/:id/auto-title', (c) => {
|
|
909
|
+
const id = c.req.param('id');
|
|
910
|
+
const row = getAutoTitle(id);
|
|
911
|
+
if (!row)
|
|
912
|
+
return c.json({ error: 'session not found' }, 404);
|
|
913
|
+
return c.json(row);
|
|
914
|
+
});
|
|
915
|
+
/**
|
|
916
|
+
* Agent-generated title. 403 until the user has flipped
|
|
917
|
+
* `autoTitle.agentEnabled` in config — the endpoint shells out to `claude`
|
|
918
|
+
* and uses their subscription, so we only fire it on explicit opt-in.
|
|
919
|
+
*/
|
|
920
|
+
app.post('/api/sessions/:id/auto-title', async (c) => {
|
|
921
|
+
const id = c.req.param('id');
|
|
922
|
+
const cfg = readAutoTitleConfig();
|
|
923
|
+
if (!cfg.agentEnabled) {
|
|
924
|
+
return c.json({ error: 'autoTitle.agentEnabled is false' }, 403);
|
|
925
|
+
}
|
|
926
|
+
const db = getDb();
|
|
927
|
+
const exists = db.prepare('SELECT 1 FROM sessions WHERE id = ?').get(id);
|
|
928
|
+
if (!exists)
|
|
929
|
+
return c.json({ error: 'session not found' }, 404);
|
|
930
|
+
try {
|
|
931
|
+
const title = await deriveAgentTitle(id);
|
|
932
|
+
setAutoTitle(id, title, 'agent');
|
|
933
|
+
return c.json(getAutoTitle(id));
|
|
934
|
+
}
|
|
935
|
+
catch (err) {
|
|
936
|
+
return c.json({ error: err.message, code: 'agent-title-failed' }, 500);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
// Notes CRUD — never truly delete, PUT with empty string is the "clear" action.
|
|
940
|
+
app.get('/api/sessions/:id/notes', (c) => {
|
|
941
|
+
const id = c.req.param('id');
|
|
942
|
+
const note = getNote(id);
|
|
943
|
+
if (!note) {
|
|
944
|
+
// 204 No Content = no row yet; client treats as "empty, not-yet-created"
|
|
945
|
+
return c.body(null, 204);
|
|
946
|
+
}
|
|
947
|
+
return c.json(note);
|
|
948
|
+
});
|
|
949
|
+
app.put('/api/sessions/:id/notes', async (c) => {
|
|
950
|
+
const id = c.req.param('id');
|
|
951
|
+
const body = (await c.req.json().catch(() => null));
|
|
952
|
+
if (!body || typeof body.content !== 'string') {
|
|
953
|
+
return c.json({ error: 'content required (string)' }, 400);
|
|
954
|
+
}
|
|
955
|
+
try {
|
|
956
|
+
const row = setNote(id, body.content);
|
|
957
|
+
return c.json(row);
|
|
958
|
+
}
|
|
959
|
+
catch (err) {
|
|
960
|
+
return c.json({ error: err.message }, 500);
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
/**
|
|
964
|
+
* ✨ Generate Note — runs claude -p over a sampled transcript and stores
|
|
965
|
+
* the resulting markdown synopsis in `session_notes.auto_synopsis`. Never
|
|
966
|
+
* overwrites the user-typed `content`. Returns the full notes row so the
|
|
967
|
+
* client can re-render manual + synopsis side by side.
|
|
968
|
+
*
|
|
969
|
+
* Gated behind the same agentEnabled config flag as ✨ Generate Title —
|
|
970
|
+
* shells out to the user's Claude subscription.
|
|
971
|
+
*/
|
|
972
|
+
app.post('/api/sessions/:id/generate-note', async (c) => {
|
|
973
|
+
const id = c.req.param('id');
|
|
974
|
+
const cfg = readAutoTitleConfig();
|
|
975
|
+
if (!cfg.agentEnabled) {
|
|
976
|
+
return c.json({ error: 'autoTitle.agentEnabled is false' }, 403);
|
|
977
|
+
}
|
|
978
|
+
try {
|
|
979
|
+
const synopsis = await deriveAgentNote(id);
|
|
980
|
+
const row = setAutoSynopsis(id, synopsis);
|
|
981
|
+
return c.json(row);
|
|
982
|
+
}
|
|
983
|
+
catch (err) {
|
|
984
|
+
const msg = err.message;
|
|
985
|
+
// 404 for missing session/messages, 500 for CLI failure.
|
|
986
|
+
const status = /no messages available/i.test(msg) ? 404 : 500;
|
|
987
|
+
return c.json({ error: msg }, status);
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
// v0.11 — semantic search config + status. Off by default. Enabling sends
|
|
991
|
+
// condensed session summaries to Claude via the local `claude` CLI; the UI
|
|
992
|
+
// surfaces this disclosure before flipping the switch.
|
|
993
|
+
app.get('/api/semantic/status', (c) => c.json(getSemanticStatus()));
|
|
994
|
+
app.put('/api/semantic/config', async (c) => {
|
|
995
|
+
const body = await c.req.json().catch(() => ({}));
|
|
996
|
+
const parsed = SemanticConfigSchema.partial().safeParse(body);
|
|
997
|
+
if (!parsed.success) {
|
|
998
|
+
return c.json({ error: 'invalid semantic config', issues: parsed.error.issues }, 400);
|
|
999
|
+
}
|
|
1000
|
+
writeSemanticConfig(parsed.data);
|
|
1001
|
+
return c.json(getSemanticStatus());
|
|
1002
|
+
});
|
|
1003
|
+
app.get('/api/semantic/config', (c) => c.json(readSemanticConfig()));
|
|
1004
|
+
// Trigger a non-blocking backfill pass. Returns immediately; progress
|
|
1005
|
+
// can be polled via /api/semantic/status.
|
|
1006
|
+
app.post('/api/semantic/backfill', async (c) => {
|
|
1007
|
+
const cfg = readSemanticConfig();
|
|
1008
|
+
if (!cfg.enabled)
|
|
1009
|
+
return c.json({ error: 'semantic search is disabled' }, 400);
|
|
1010
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
1011
|
+
const limit = Math.max(1, Math.min(5000, Number(body.limit ?? 200)));
|
|
1012
|
+
void semanticBackfill({ limit, force: !!body.force }).catch((err) => console.error('[semantic.backfill] error:', err));
|
|
1013
|
+
return c.json({ ok: true, limit });
|
|
1014
|
+
});
|
|
1015
|
+
// One-shot summarize for a single session (e.g. from the transcript header).
|
|
1016
|
+
app.post('/api/semantic/sessions/:id', async (c) => {
|
|
1017
|
+
const cfg = readSemanticConfig();
|
|
1018
|
+
if (!cfg.enabled)
|
|
1019
|
+
return c.json({ error: 'semantic search is disabled' }, 400);
|
|
1020
|
+
const id = c.req.param('id');
|
|
1021
|
+
const result = await processSemanticSession(id);
|
|
1022
|
+
return c.json(result);
|
|
1023
|
+
});
|
|
1024
|
+
// v0.7 — Vector tier endpoints (Pro-only)
|
|
1025
|
+
app.get('/api/semantic/vector-status', (c) => {
|
|
1026
|
+
const embedder = getEmbedderStatus();
|
|
1027
|
+
const worker = getWorkerStatus();
|
|
1028
|
+
const modelInstalled = isModelInstalled();
|
|
1029
|
+
return c.json({ embedder, worker, modelInstalled });
|
|
1030
|
+
});
|
|
1031
|
+
app.post('/api/semantic/install', requireProMiddleware, async (c) => {
|
|
1032
|
+
if (isModelInstalled())
|
|
1033
|
+
return c.json({ ok: true, status: 'already_installed' });
|
|
1034
|
+
try {
|
|
1035
|
+
await downloadModel();
|
|
1036
|
+
await loadEmbedder();
|
|
1037
|
+
startWorker();
|
|
1038
|
+
return c.json({ ok: true, status: 'installed' });
|
|
1039
|
+
}
|
|
1040
|
+
catch (err) {
|
|
1041
|
+
const msg = err instanceof Error ? err.message : 'unknown error';
|
|
1042
|
+
return c.json({ ok: false, error: msg }, 500);
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
app.get('/api/sessions/:id/similar', requireProMiddleware, async (c) => {
|
|
1046
|
+
if (!getEmbedderStatus().loaded) {
|
|
1047
|
+
return c.json({ error: 'vector model not loaded' }, 503);
|
|
1048
|
+
}
|
|
1049
|
+
const id = c.req.param('id');
|
|
1050
|
+
const limit = Math.max(1, Math.min(50, Number(c.req.query('limit') ?? 10)));
|
|
1051
|
+
try {
|
|
1052
|
+
const results = await findSimilarSessions(id, limit);
|
|
1053
|
+
return c.json({ sessionId: id, similar: results });
|
|
1054
|
+
}
|
|
1055
|
+
catch (err) {
|
|
1056
|
+
const msg = err instanceof Error ? err.message : 'unknown error';
|
|
1057
|
+
return c.json({ error: msg }, 500);
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
app.get('/api/search', requireProMiddleware, async (c) => {
|
|
1061
|
+
const db = getDb();
|
|
1062
|
+
const q = c.req.query('q')?.trim();
|
|
1063
|
+
if (!q)
|
|
1064
|
+
return c.json({ query: '', hits: [], tags: [] });
|
|
1065
|
+
// Bound the query length — pathological inputs (thousands of tokens)
|
|
1066
|
+
// would build huge SQL and an expensive FTS5 plan. 500 is 5× any sane
|
|
1067
|
+
// search.
|
|
1068
|
+
if (q.length > 500)
|
|
1069
|
+
return c.json({ error: 'query too long (max 500 chars)' }, 400);
|
|
1070
|
+
const project = c.req.query('project');
|
|
1071
|
+
// Split query tokens: anything starting with '#' becomes a tag filter,
|
|
1072
|
+
// everything else goes to FTS. `#auth-fix migration` = sessions tagged
|
|
1073
|
+
// 'auth-fix' AND message text contains 'migration'.
|
|
1074
|
+
const tokens = q.split(/\s+/).filter((t) => t.length > 0);
|
|
1075
|
+
const tagTokens = tokens.filter((t) => t.startsWith('#')).map((t) => normalizeTag(t)).filter(Boolean);
|
|
1076
|
+
const textTokens = tokens.filter((t) => !t.startsWith('#'));
|
|
1077
|
+
const terms = textTokens.map((t) => `"${t.replace(/"/g, '')}"`);
|
|
1078
|
+
const fts = terms.join(' ');
|
|
1079
|
+
const limit = Math.max(1, Math.min(200, Number(c.req.query('limit') ?? 30)));
|
|
1080
|
+
// Tag-only queries: no FTS at all, just list matching sessions' first
|
|
1081
|
+
// user message as the snippet.
|
|
1082
|
+
if (terms.length === 0 && tagTokens.length > 0) {
|
|
1083
|
+
let sql = `
|
|
1084
|
+
SELECT s.id AS session_id,
|
|
1085
|
+
s.id AS message_uuid,
|
|
1086
|
+
p.name AS project,
|
|
1087
|
+
s.started_at,
|
|
1088
|
+
COALESCE(s.first_user_message, '') AS snippet,
|
|
1089
|
+
CAST(NULL AS TEXT) AS role,
|
|
1090
|
+
CAST(NULL AS TEXT) AS timestamp,
|
|
1091
|
+
NULLIF(sa.alias, '') AS alias
|
|
1092
|
+
FROM sessions s
|
|
1093
|
+
JOIN projects p ON p.id = s.project_id
|
|
1094
|
+
LEFT JOIN session_aliases sa ON sa.session_id = s.id
|
|
1095
|
+
WHERE 1=1
|
|
1096
|
+
`;
|
|
1097
|
+
const params = { limit };
|
|
1098
|
+
if (project) {
|
|
1099
|
+
sql += ' AND (p.name LIKE @proj OR p.decoded_path LIKE @proj)';
|
|
1100
|
+
params.proj = `%${project}%`;
|
|
1101
|
+
}
|
|
1102
|
+
tagTokens.forEach((tag, i) => {
|
|
1103
|
+
sql += ` AND s.id IN (SELECT session_id FROM session_tags WHERE tag = @tag_${i})`;
|
|
1104
|
+
params[`tag_${i}`] = tag;
|
|
1105
|
+
});
|
|
1106
|
+
sql += ' ORDER BY COALESCE(s.started_at, \'\') DESC LIMIT @limit';
|
|
1107
|
+
const rows = db.prepare(sql).all(params);
|
|
1108
|
+
return c.json({ query: q, hits: rows, tags: tagTokens });
|
|
1109
|
+
}
|
|
1110
|
+
let sql = `
|
|
1111
|
+
SELECT m.session_id AS session_id,
|
|
1112
|
+
m.uuid AS message_uuid,
|
|
1113
|
+
p.name AS project,
|
|
1114
|
+
s.started_at,
|
|
1115
|
+
snippet(messages_fts, 0, '<<', '>>', '…', 20) AS snippet,
|
|
1116
|
+
m.role,
|
|
1117
|
+
m.timestamp,
|
|
1118
|
+
NULLIF(sa.alias, '') AS alias
|
|
1119
|
+
FROM messages_fts
|
|
1120
|
+
JOIN messages m ON m.rowid = messages_fts.rowid
|
|
1121
|
+
JOIN sessions s ON s.id = m.session_id
|
|
1122
|
+
JOIN projects p ON p.id = s.project_id
|
|
1123
|
+
LEFT JOIN session_aliases sa ON sa.session_id = s.id
|
|
1124
|
+
WHERE messages_fts MATCH @fts
|
|
1125
|
+
`;
|
|
1126
|
+
const params = { fts, limit };
|
|
1127
|
+
if (project) {
|
|
1128
|
+
sql += ' AND (p.name LIKE @proj OR p.decoded_path LIKE @proj)';
|
|
1129
|
+
params.proj = `%${project}%`;
|
|
1130
|
+
}
|
|
1131
|
+
tagTokens.forEach((tag, i) => {
|
|
1132
|
+
sql += ` AND s.id IN (SELECT session_id FROM session_tags WHERE tag = @tag_${i})`;
|
|
1133
|
+
params[`tag_${i}`] = tag;
|
|
1134
|
+
});
|
|
1135
|
+
sql += ' ORDER BY bm25(messages_fts) LIMIT @limit';
|
|
1136
|
+
const rows = db.prepare(sql).all(params);
|
|
1137
|
+
const ftsHits = rows.map((r) => ({
|
|
1138
|
+
...r,
|
|
1139
|
+
matched_via: 'fts',
|
|
1140
|
+
}));
|
|
1141
|
+
const mode = c.req.query('mode');
|
|
1142
|
+
if (mode !== 'semantic') {
|
|
1143
|
+
return c.json({ query: q, hits: ftsHits, tags: tagTokens });
|
|
1144
|
+
}
|
|
1145
|
+
// v0.11 — semantic mode: run sessions_fts over (summary, keywords).
|
|
1146
|
+
let semanticRows = [];
|
|
1147
|
+
try {
|
|
1148
|
+
let semSql = `
|
|
1149
|
+
SELECT s.id AS session_id,
|
|
1150
|
+
s.id AS message_uuid,
|
|
1151
|
+
p.name AS project,
|
|
1152
|
+
s.started_at,
|
|
1153
|
+
COALESCE(NULLIF(ss.summary, ''), s.first_user_message, '') AS snippet,
|
|
1154
|
+
CAST(NULL AS TEXT) AS role,
|
|
1155
|
+
CAST(NULL AS TEXT) AS timestamp,
|
|
1156
|
+
NULLIF(sa.alias, '') AS alias,
|
|
1157
|
+
bm25(sessions_fts) AS rank
|
|
1158
|
+
FROM sessions_fts
|
|
1159
|
+
JOIN session_semantic ss ON ss.rowid = sessions_fts.rowid
|
|
1160
|
+
JOIN sessions s ON s.id = ss.session_id
|
|
1161
|
+
JOIN projects p ON p.id = s.project_id
|
|
1162
|
+
LEFT JOIN session_aliases sa ON sa.session_id = s.id
|
|
1163
|
+
WHERE sessions_fts MATCH @fts
|
|
1164
|
+
`;
|
|
1165
|
+
const semParams = { fts, limit };
|
|
1166
|
+
if (project) {
|
|
1167
|
+
semSql += ' AND (p.name LIKE @proj OR p.decoded_path LIKE @proj)';
|
|
1168
|
+
semParams.proj = `%${project}%`;
|
|
1169
|
+
}
|
|
1170
|
+
tagTokens.forEach((tag, i) => {
|
|
1171
|
+
semSql += ` AND s.id IN (SELECT session_id FROM session_tags WHERE tag = @tag_${i})`;
|
|
1172
|
+
semParams[`tag_${i}`] = tag;
|
|
1173
|
+
});
|
|
1174
|
+
semSql += ' ORDER BY rank LIMIT @limit';
|
|
1175
|
+
semanticRows = db.prepare(semSql).all(semParams);
|
|
1176
|
+
}
|
|
1177
|
+
catch (err) {
|
|
1178
|
+
console.error('[search.semantic] failed:', err);
|
|
1179
|
+
}
|
|
1180
|
+
// v0.7 — vector lane via RRF fusion when embedder is loaded.
|
|
1181
|
+
if (getEmbedderStatus().loaded) {
|
|
1182
|
+
try {
|
|
1183
|
+
const vectorHits = await vectorSearch(q, limit);
|
|
1184
|
+
const bm25Lane = ftsHits.map((h) => ({
|
|
1185
|
+
id: String(h.session_id),
|
|
1186
|
+
data: h,
|
|
1187
|
+
lane: 'bm25',
|
|
1188
|
+
}));
|
|
1189
|
+
const summaryLane = semanticRows.map((r) => ({
|
|
1190
|
+
id: String(r.session_id),
|
|
1191
|
+
data: r,
|
|
1192
|
+
lane: 'summary',
|
|
1193
|
+
}));
|
|
1194
|
+
const vectorLane = vectorHits.map((vh) => ({
|
|
1195
|
+
id: vh.sessionId,
|
|
1196
|
+
data: { session_id: vh.sessionId, snippet: vh.text, matched_via: 'vector' },
|
|
1197
|
+
lane: 'vector',
|
|
1198
|
+
}));
|
|
1199
|
+
const fused = fuseResults([bm25Lane, summaryLane, vectorLane]).slice(0, limit);
|
|
1200
|
+
const hits = fused.map((f) => ({
|
|
1201
|
+
...f.data,
|
|
1202
|
+
session_id: f.id,
|
|
1203
|
+
rrf_score: f.score,
|
|
1204
|
+
lanes: f.lanes,
|
|
1205
|
+
matched_via: f.lanes.length > 1 ? 'fused' : f.lanes[0],
|
|
1206
|
+
}));
|
|
1207
|
+
return c.json({ query: q, hits, tags: tagTokens, mode: 'semantic', fusion: 'rrf' });
|
|
1208
|
+
}
|
|
1209
|
+
catch (err) {
|
|
1210
|
+
console.error('[search.vector] failed, falling back:', err);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
// Fallback: simple merge without vector lane.
|
|
1214
|
+
const seenSessionIds = new Set(ftsHits.map((h) => String(h.session_id)));
|
|
1215
|
+
const semHits = semanticRows
|
|
1216
|
+
.filter((r) => !seenSessionIds.has(String(r.session_id)))
|
|
1217
|
+
.map(({ rank: _rank, ...rest }) => ({
|
|
1218
|
+
...rest,
|
|
1219
|
+
matched_via: 'semantic',
|
|
1220
|
+
}));
|
|
1221
|
+
const merged = [...ftsHits, ...semHits].slice(0, limit);
|
|
1222
|
+
return c.json({ query: q, hits: merged, tags: tagTokens, mode: 'semantic' });
|
|
1223
|
+
});
|
|
1224
|
+
// Context export — returns markdown ready to paste into a new Claude conversation.
|
|
1225
|
+
app.get('/api/sessions/:id/context', requireProMiddleware, (c) => {
|
|
1226
|
+
const db = getDb();
|
|
1227
|
+
const id = c.req.param('id');
|
|
1228
|
+
const mode = c.req.query('mode') === 'full' ? 'full' : 'condensed';
|
|
1229
|
+
const includeSidechain = c.req.query('subagents') === '1';
|
|
1230
|
+
const prelude = c.req.query('prelude') ?? null;
|
|
1231
|
+
const session = db
|
|
1232
|
+
.prepare(`SELECT s.id, p.name AS project_name, p.decoded_path,
|
|
1233
|
+
s.started_at, s.ended_at, s.message_count, s.git_branch
|
|
1234
|
+
FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
1235
|
+
WHERE s.id = ?`)
|
|
1236
|
+
.get(id);
|
|
1237
|
+
if (!session)
|
|
1238
|
+
return c.json({ error: 'not found' }, 404);
|
|
1239
|
+
const messages = db
|
|
1240
|
+
.prepare(`SELECT uuid, type, role, timestamp, is_sidechain, content_text, tool_names
|
|
1241
|
+
FROM messages
|
|
1242
|
+
WHERE session_id = ?
|
|
1243
|
+
ORDER BY COALESCE(timestamp, ''), rowid`)
|
|
1244
|
+
.all(id);
|
|
1245
|
+
const markdown = formatSessionAsContext(session, messages, {
|
|
1246
|
+
mode,
|
|
1247
|
+
includeSidechain,
|
|
1248
|
+
prelude,
|
|
1249
|
+
});
|
|
1250
|
+
return c.text(markdown);
|
|
1251
|
+
});
|
|
1252
|
+
/**
|
|
1253
|
+
* v0.8 — Collections. User-curated, hierarchical groupings of sessions.
|
|
1254
|
+
* Every mutation runs in a transaction inside `src/utils/collections.ts`,
|
|
1255
|
+
* which also appends to the event log and rewrites the plain-text mirror
|
|
1256
|
+
* at ~/.recall/collections.json. The server here is a thin wrapper.
|
|
1257
|
+
*/
|
|
1258
|
+
app.get('/api/collections', (c) => {
|
|
1259
|
+
const includeArchived = c.req.query('archived') === '1';
|
|
1260
|
+
return c.json({ collections: listCollections(includeArchived) });
|
|
1261
|
+
});
|
|
1262
|
+
app.get('/api/collections/:id', (c) => {
|
|
1263
|
+
const id = c.req.param('id');
|
|
1264
|
+
const col = getCollection(id);
|
|
1265
|
+
if (!col)
|
|
1266
|
+
return c.json({ error: 'not found' }, 404);
|
|
1267
|
+
const members = sessionsInCollection(id, /* includeDescendants */ true);
|
|
1268
|
+
return c.json({ collection: col, members });
|
|
1269
|
+
});
|
|
1270
|
+
app.post('/api/collections', async (c) => {
|
|
1271
|
+
const body = (await c.req.json().catch(() => null));
|
|
1272
|
+
if (!body || typeof body.name !== 'string') {
|
|
1273
|
+
return c.json({ error: 'name required' }, 400);
|
|
1274
|
+
}
|
|
1275
|
+
try {
|
|
1276
|
+
const row = createCollection({
|
|
1277
|
+
name: body.name,
|
|
1278
|
+
description: body.description ?? null,
|
|
1279
|
+
icon: body.icon ?? null,
|
|
1280
|
+
color: body.color ?? null,
|
|
1281
|
+
parent_id: body.parent_id ?? null,
|
|
1282
|
+
sort_key: body.sort_key,
|
|
1283
|
+
});
|
|
1284
|
+
return c.json(row, 201);
|
|
1285
|
+
}
|
|
1286
|
+
catch (err) {
|
|
1287
|
+
return c.json({ error: err.message }, 400);
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
app.patch('/api/collections/:id', async (c) => {
|
|
1291
|
+
const id = c.req.param('id');
|
|
1292
|
+
const body = (await c.req.json().catch(() => null));
|
|
1293
|
+
if (!body)
|
|
1294
|
+
return c.json({ error: 'body required' }, 400);
|
|
1295
|
+
try {
|
|
1296
|
+
const row = patchCollection(id, body);
|
|
1297
|
+
return c.json(row);
|
|
1298
|
+
}
|
|
1299
|
+
catch (err) {
|
|
1300
|
+
return c.json({ error: err.message }, 400);
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
app.post('/api/collections/:id/archive', (c) => {
|
|
1304
|
+
const id = c.req.param('id');
|
|
1305
|
+
try {
|
|
1306
|
+
const row = archiveCollection(id);
|
|
1307
|
+
return c.json(row);
|
|
1308
|
+
}
|
|
1309
|
+
catch (err) {
|
|
1310
|
+
return c.json({ error: err.message }, 404);
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
app.post('/api/collections/:id/restore', (c) => {
|
|
1314
|
+
const id = c.req.param('id');
|
|
1315
|
+
try {
|
|
1316
|
+
const row = restoreCollection(id);
|
|
1317
|
+
return c.json(row);
|
|
1318
|
+
}
|
|
1319
|
+
catch (err) {
|
|
1320
|
+
return c.json({ error: err.message }, 404);
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
app.post('/api/collections/:id/members', async (c) => {
|
|
1324
|
+
const id = c.req.param('id');
|
|
1325
|
+
const body = (await c.req.json().catch(() => null));
|
|
1326
|
+
if (!body || typeof body.session_id !== 'string') {
|
|
1327
|
+
return c.json({ error: 'session_id required' }, 400);
|
|
1328
|
+
}
|
|
1329
|
+
try {
|
|
1330
|
+
const result = addSessionToCollection(id, body.session_id, body.note ?? null);
|
|
1331
|
+
return c.json(result);
|
|
1332
|
+
}
|
|
1333
|
+
catch (err) {
|
|
1334
|
+
return c.json({ error: err.message }, 400);
|
|
1335
|
+
}
|
|
1336
|
+
});
|
|
1337
|
+
app.delete('/api/collections/:id/members/:sid', (c) => {
|
|
1338
|
+
const id = c.req.param('id');
|
|
1339
|
+
const sid = c.req.param('sid');
|
|
1340
|
+
try {
|
|
1341
|
+
const result = removeSessionFromCollection(id, sid);
|
|
1342
|
+
return c.json(result);
|
|
1343
|
+
}
|
|
1344
|
+
catch (err) {
|
|
1345
|
+
return c.json({ error: err.message }, 400);
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
app.get('/api/sessions/:id/collections', (c) => {
|
|
1349
|
+
const id = c.req.param('id');
|
|
1350
|
+
return c.json({ collections: collectionsForSession(id) });
|
|
1351
|
+
});
|
|
1352
|
+
/**
|
|
1353
|
+
* v0.15 T6 — Auto-collections. Rules bind (type, pattern) pairs to
|
|
1354
|
+
* target collections; suggestions propose new rules for clusters the
|
|
1355
|
+
* daemon spots in the corpus. Every mutation is wrapped by the util
|
|
1356
|
+
* layer's transaction + JSON mirror, so the server is a thin shell.
|
|
1357
|
+
*/
|
|
1358
|
+
const AUTO_RULE_TYPES = [
|
|
1359
|
+
'cwd-prefix',
|
|
1360
|
+
'project-id',
|
|
1361
|
+
'tag',
|
|
1362
|
+
'plan-file',
|
|
1363
|
+
'git-branch-prefix',
|
|
1364
|
+
];
|
|
1365
|
+
app.get('/api/auto-collections/rules', (c) => c.json({ rules: listAutoRules() }));
|
|
1366
|
+
app.post('/api/auto-collections/rules', async (c) => {
|
|
1367
|
+
const body = (await c.req.json().catch(() => null));
|
|
1368
|
+
if (!body ||
|
|
1369
|
+
typeof body.name !== 'string' ||
|
|
1370
|
+
typeof body.pattern !== 'string' ||
|
|
1371
|
+
!body.type ||
|
|
1372
|
+
!AUTO_RULE_TYPES.includes(body.type)) {
|
|
1373
|
+
return c.json({ error: 'name, type, pattern required (type must be a known matcher)' }, 400);
|
|
1374
|
+
}
|
|
1375
|
+
try {
|
|
1376
|
+
const rule = createAutoRule({
|
|
1377
|
+
name: body.name,
|
|
1378
|
+
type: body.type,
|
|
1379
|
+
pattern: body.pattern,
|
|
1380
|
+
collection_id: body.collection_id,
|
|
1381
|
+
parent_collection_id: body.parent_collection_id,
|
|
1382
|
+
priority: body.priority,
|
|
1383
|
+
enabled: body.enabled,
|
|
1384
|
+
});
|
|
1385
|
+
return c.json(rule, 201);
|
|
1386
|
+
}
|
|
1387
|
+
catch (err) {
|
|
1388
|
+
return c.json({ error: err.message }, 400);
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
app.patch('/api/auto-collections/rules/:id', async (c) => {
|
|
1392
|
+
const id = c.req.param('id');
|
|
1393
|
+
const body = (await c.req.json().catch(() => null));
|
|
1394
|
+
if (!body)
|
|
1395
|
+
return c.json({ error: 'body required' }, 400);
|
|
1396
|
+
try {
|
|
1397
|
+
const rule = patchAutoRule(id, body);
|
|
1398
|
+
return c.json(rule);
|
|
1399
|
+
}
|
|
1400
|
+
catch (err) {
|
|
1401
|
+
return c.json({ error: err.message }, 400);
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
app.delete('/api/auto-collections/rules/:id', (c) => {
|
|
1405
|
+
const id = c.req.param('id');
|
|
1406
|
+
try {
|
|
1407
|
+
const result = deleteAutoRule(id);
|
|
1408
|
+
return c.json(result);
|
|
1409
|
+
}
|
|
1410
|
+
catch (err) {
|
|
1411
|
+
return c.json({ error: err.message }, 400);
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
app.get('/api/auto-collections/suggestions', (c) => {
|
|
1415
|
+
const includeDismissed = c.req.query('dismissed') === '1';
|
|
1416
|
+
return c.json({
|
|
1417
|
+
suggestions: listAutoSuggestions({ includeDismissed }),
|
|
1418
|
+
});
|
|
1419
|
+
});
|
|
1420
|
+
app.post('/api/auto-collections/suggestions/:id/accept', (c) => {
|
|
1421
|
+
const id = c.req.param('id');
|
|
1422
|
+
try {
|
|
1423
|
+
const rule = acceptAutoSuggestion(id);
|
|
1424
|
+
return c.json({ rule });
|
|
1425
|
+
}
|
|
1426
|
+
catch (err) {
|
|
1427
|
+
return c.json({ error: err.message }, 400);
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
app.post('/api/auto-collections/suggestions/:id/dismiss', (c) => {
|
|
1431
|
+
const id = c.req.param('id');
|
|
1432
|
+
try {
|
|
1433
|
+
dismissAutoSuggestion(id);
|
|
1434
|
+
return c.json({ ok: true });
|
|
1435
|
+
}
|
|
1436
|
+
catch (err) {
|
|
1437
|
+
return c.json({ error: err.message }, 400);
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
app.post('/api/auto-collections/detect', (c) => {
|
|
1441
|
+
// Manual re-scan — surfaced in the UI for users who don't want to wait
|
|
1442
|
+
// for the 6h background pass.
|
|
1443
|
+
const suggestions = detectAutoSuggestions();
|
|
1444
|
+
return c.json({ suggestions });
|
|
1445
|
+
});
|
|
1446
|
+
app.get('/api/auto-collections/suggestions/:id/preview', (c) => {
|
|
1447
|
+
const id = c.req.param('id');
|
|
1448
|
+
const limit = Math.max(1, Math.min(20, Number(c.req.query('limit')) || 3));
|
|
1449
|
+
const all = listAutoSuggestions({ includeDismissed: false });
|
|
1450
|
+
const match = all.find((s) => s.id === id);
|
|
1451
|
+
if (!match)
|
|
1452
|
+
return c.json({ error: 'suggestion not found' }, 404);
|
|
1453
|
+
const sessions = previewAutoSuggestion(match.type, match.pattern, limit);
|
|
1454
|
+
return c.json({ sessions });
|
|
1455
|
+
});
|
|
1456
|
+
app.get('/api/auto-collections/parents', (c) => {
|
|
1457
|
+
// Lightweight helper for the UI: which collection ids are auto-managed?
|
|
1458
|
+
const ids = Array.from(autoCollectionIdSet());
|
|
1459
|
+
return c.json({ auto_collection_ids: ids });
|
|
1460
|
+
});
|
|
1461
|
+
// v0.15 Threads — intent-grouping DAG. See src/utils/threads.ts.
|
|
1462
|
+
app.get('/api/threads', (c) => {
|
|
1463
|
+
const includeArchived = c.req.query('archived') === '1';
|
|
1464
|
+
return c.json({ threads: listThreads({ includeArchived }) });
|
|
1465
|
+
});
|
|
1466
|
+
app.get('/api/threads/:id', (c) => {
|
|
1467
|
+
const id = c.req.param('id');
|
|
1468
|
+
const detail = getThread(id);
|
|
1469
|
+
if (!detail)
|
|
1470
|
+
return c.json({ error: 'thread not found' }, 404);
|
|
1471
|
+
// Enrich each edge with alias_source from the terminal registry. The DB
|
|
1472
|
+
// doesn't persist this — it's derived at request time the same way as the
|
|
1473
|
+
// sessions list endpoint, so the graph applies the same display precedence
|
|
1474
|
+
// (agent ✨ title > manual alias > heuristic title > auto-alias).
|
|
1475
|
+
const edges = detail.edges.map((e) => ({
|
|
1476
|
+
...e,
|
|
1477
|
+
alias_source: e.alias == null
|
|
1478
|
+
? null
|
|
1479
|
+
: terminalRegistry.isSessionAutoLinked(e.session_id)
|
|
1480
|
+
? 'auto'
|
|
1481
|
+
: 'manual',
|
|
1482
|
+
}));
|
|
1483
|
+
return c.json({ thread: { ...detail, edges } });
|
|
1484
|
+
});
|
|
1485
|
+
app.post('/api/threads', async (c) => {
|
|
1486
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
1487
|
+
if (!body.name)
|
|
1488
|
+
return c.json({ error: 'name required' }, 400);
|
|
1489
|
+
try {
|
|
1490
|
+
const thread = createThread({
|
|
1491
|
+
name: body.name,
|
|
1492
|
+
summary: body.summary ?? null,
|
|
1493
|
+
originSessionId: body.originSessionId,
|
|
1494
|
+
});
|
|
1495
|
+
return c.json({ thread });
|
|
1496
|
+
}
|
|
1497
|
+
catch (err) {
|
|
1498
|
+
return c.json({ error: err.message }, 400);
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
app.patch('/api/threads/:id', async (c) => {
|
|
1502
|
+
const id = c.req.param('id');
|
|
1503
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
1504
|
+
try {
|
|
1505
|
+
if (body.name)
|
|
1506
|
+
renameThread(id, body.name);
|
|
1507
|
+
if (body.close)
|
|
1508
|
+
closeThread(id);
|
|
1509
|
+
if (body.reopen)
|
|
1510
|
+
reopenThread(id);
|
|
1511
|
+
if (body.archive)
|
|
1512
|
+
archiveThread(id);
|
|
1513
|
+
const detail = getThread(id);
|
|
1514
|
+
if (!detail)
|
|
1515
|
+
return c.json({ error: 'thread not found' }, 404);
|
|
1516
|
+
return c.json({ thread: detail });
|
|
1517
|
+
}
|
|
1518
|
+
catch (err) {
|
|
1519
|
+
return c.json({ error: err.message }, 400);
|
|
1520
|
+
}
|
|
1521
|
+
});
|
|
1522
|
+
app.post('/api/threads/:id/sessions', async (c) => {
|
|
1523
|
+
const threadId = c.req.param('id');
|
|
1524
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
1525
|
+
if (!body.sessionId)
|
|
1526
|
+
return c.json({ error: 'sessionId required' }, 400);
|
|
1527
|
+
try {
|
|
1528
|
+
const edge = addSessionToThread({
|
|
1529
|
+
threadId,
|
|
1530
|
+
sessionId: body.sessionId,
|
|
1531
|
+
parentSessionId: body.parentSessionId ?? null,
|
|
1532
|
+
role: body.role,
|
|
1533
|
+
});
|
|
1534
|
+
return c.json({ edge });
|
|
1535
|
+
}
|
|
1536
|
+
catch (err) {
|
|
1537
|
+
return c.json({ error: err.message }, 400);
|
|
1538
|
+
}
|
|
1539
|
+
});
|
|
1540
|
+
app.delete('/api/threads/:id/sessions/:sessionId', (c) => {
|
|
1541
|
+
const threadId = c.req.param('id');
|
|
1542
|
+
const sessionId = c.req.param('sessionId');
|
|
1543
|
+
const result = removeSessionFromThread(threadId, sessionId);
|
|
1544
|
+
return c.json(result);
|
|
1545
|
+
});
|
|
1546
|
+
app.patch('/api/threads/:id/sessions/:sessionId', async (c) => {
|
|
1547
|
+
const threadId = c.req.param('id');
|
|
1548
|
+
const sessionId = c.req.param('sessionId');
|
|
1549
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
1550
|
+
try {
|
|
1551
|
+
const edge = setParent(threadId, sessionId, body.parentSessionId ?? null);
|
|
1552
|
+
return c.json({ edge });
|
|
1553
|
+
}
|
|
1554
|
+
catch (err) {
|
|
1555
|
+
return c.json({ error: err.message }, 400);
|
|
1556
|
+
}
|
|
1557
|
+
});
|
|
1558
|
+
app.post('/api/threads/:id/merge', async (c) => {
|
|
1559
|
+
const destId = c.req.param('id');
|
|
1560
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
1561
|
+
if (!body.sourceId)
|
|
1562
|
+
return c.json({ error: 'sourceId required' }, 400);
|
|
1563
|
+
try {
|
|
1564
|
+
const detail = mergeThreads(body.sourceId, destId);
|
|
1565
|
+
return c.json({ thread: detail });
|
|
1566
|
+
}
|
|
1567
|
+
catch (err) {
|
|
1568
|
+
return c.json({ error: err.message }, 400);
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
app.post('/api/threads/:id/split', async (c) => {
|
|
1572
|
+
const threadId = c.req.param('id');
|
|
1573
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
1574
|
+
if (!body.sessionIds?.length || !body.newThreadName) {
|
|
1575
|
+
return c.json({ error: 'sessionIds and newThreadName required' }, 400);
|
|
1576
|
+
}
|
|
1577
|
+
try {
|
|
1578
|
+
const detail = splitThread({
|
|
1579
|
+
threadId,
|
|
1580
|
+
sessionIds: body.sessionIds,
|
|
1581
|
+
newThreadName: body.newThreadName,
|
|
1582
|
+
});
|
|
1583
|
+
return c.json({ thread: detail });
|
|
1584
|
+
}
|
|
1585
|
+
catch (err) {
|
|
1586
|
+
return c.json({ error: err.message }, 400);
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
app.get('/api/sessions/:id/threads', (c) => {
|
|
1590
|
+
const sessionId = c.req.param('id');
|
|
1591
|
+
return c.json({ threads: threadsForSession(sessionId) });
|
|
1592
|
+
});
|
|
1593
|
+
/**
|
|
1594
|
+
* v0.7 Bucket 5: bulk thread title generation.
|
|
1595
|
+
*
|
|
1596
|
+
* POST /api/threads/:id/titles/generate kicks off a detached walk and
|
|
1597
|
+
* returns {jobId} immediately. The client then opens an SSE stream on
|
|
1598
|
+
* /api/jobs/:jobId/stream to follow progress, or DELETEs the job to cancel.
|
|
1599
|
+
*
|
|
1600
|
+
* Pre-flight count: when the client asks for `?count=1` (or POST body
|
|
1601
|
+
* { count: true }) we return the number of sessions in the thread that
|
|
1602
|
+
* already have an agent-sourced title so the UI can render the
|
|
1603
|
+
* "skip already-titled vs regenerate all" choice. No job is started.
|
|
1604
|
+
*/
|
|
1605
|
+
app.get('/api/threads/:id/titles/preflight', (c) => {
|
|
1606
|
+
const threadId = c.req.param('id');
|
|
1607
|
+
const detail = getThread(threadId);
|
|
1608
|
+
if (!detail)
|
|
1609
|
+
return c.json({ error: 'thread not found' }, 404);
|
|
1610
|
+
const db = getDb();
|
|
1611
|
+
let alreadyTitled = 0;
|
|
1612
|
+
for (const e of detail.edges) {
|
|
1613
|
+
const row = db
|
|
1614
|
+
.prepare(`SELECT auto_title_source FROM sessions WHERE id = ?`)
|
|
1615
|
+
.get(e.session_id);
|
|
1616
|
+
if (row?.auto_title_source === 'agent')
|
|
1617
|
+
alreadyTitled += 1;
|
|
1618
|
+
}
|
|
1619
|
+
return c.json({
|
|
1620
|
+
total: detail.edges.length,
|
|
1621
|
+
alreadyTitled,
|
|
1622
|
+
untitled: detail.edges.length - alreadyTitled,
|
|
1623
|
+
});
|
|
1624
|
+
});
|
|
1625
|
+
app.post('/api/threads/:id/titles/generate', async (c) => {
|
|
1626
|
+
const threadId = c.req.param('id');
|
|
1627
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
1628
|
+
const detail = getThread(threadId);
|
|
1629
|
+
if (!detail)
|
|
1630
|
+
return c.json({ error: 'thread not found' }, 404);
|
|
1631
|
+
if (detail.edges.length === 0) {
|
|
1632
|
+
return c.json({ error: 'thread has no sessions' }, 400);
|
|
1633
|
+
}
|
|
1634
|
+
// BUCKET 6 INTEGRATION POINT: model resolution. Today we read the user's
|
|
1635
|
+
// auto-tag model preference if no explicit override is supplied. Bucket 6
|
|
1636
|
+
// will replace this with a richer engine-config lookup keyed off feature
|
|
1637
|
+
// (autoTitle vs autoTag vs others) so different features can route to
|
|
1638
|
+
// different models from a single source of truth.
|
|
1639
|
+
const cfg = readAutoTagConfig();
|
|
1640
|
+
const model = body.model ?? cfg.model;
|
|
1641
|
+
const jobId = startBulkTitleJob({
|
|
1642
|
+
threadId,
|
|
1643
|
+
force: body.force ?? false,
|
|
1644
|
+
model,
|
|
1645
|
+
});
|
|
1646
|
+
return c.json({ jobId });
|
|
1647
|
+
});
|
|
1648
|
+
app.get('/api/jobs/:jobId/stream', (c) => {
|
|
1649
|
+
const jobId = c.req.param('jobId');
|
|
1650
|
+
const snap = getJobSnapshot(jobId);
|
|
1651
|
+
if (!snap)
|
|
1652
|
+
return c.json({ error: 'job not found' }, 404);
|
|
1653
|
+
// Standard SSE resume: client sends Last-Event-ID to pick up where it
|
|
1654
|
+
// left off. Hono passes the header through; we coerce to a number so the
|
|
1655
|
+
// generator can skip already-emitted events.
|
|
1656
|
+
const lastEventId = Number(c.req.header('Last-Event-ID') ?? 0);
|
|
1657
|
+
return streamSSE(c, async (stream) => {
|
|
1658
|
+
let closed = false;
|
|
1659
|
+
const heartbeat = setInterval(() => {
|
|
1660
|
+
if (closed)
|
|
1661
|
+
return;
|
|
1662
|
+
stream.writeSSE({ event: 'heartbeat', data: '' }).catch(() => {
|
|
1663
|
+
closed = true;
|
|
1664
|
+
});
|
|
1665
|
+
}, 15_000);
|
|
1666
|
+
try {
|
|
1667
|
+
for await (const ev of subscribeJob(jobId, lastEventId)) {
|
|
1668
|
+
if (closed)
|
|
1669
|
+
break;
|
|
1670
|
+
await stream.writeSSE({
|
|
1671
|
+
id: String(ev.id),
|
|
1672
|
+
event: ev.kind,
|
|
1673
|
+
data: JSON.stringify(ev.data),
|
|
1674
|
+
});
|
|
1675
|
+
if (ev.kind === 'done')
|
|
1676
|
+
break;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
finally {
|
|
1680
|
+
closed = true;
|
|
1681
|
+
clearInterval(heartbeat);
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
});
|
|
1685
|
+
app.get('/api/jobs/:jobId', (c) => {
|
|
1686
|
+
const snap = getJobSnapshot(c.req.param('jobId'));
|
|
1687
|
+
if (!snap)
|
|
1688
|
+
return c.json({ error: 'job not found' }, 404);
|
|
1689
|
+
return c.json(snap);
|
|
1690
|
+
});
|
|
1691
|
+
app.delete('/api/jobs/:jobId', (c) => {
|
|
1692
|
+
const ok = cancelJob(c.req.param('jobId'));
|
|
1693
|
+
if (!ok)
|
|
1694
|
+
return c.json({ error: 'job not found or already done' }, 404);
|
|
1695
|
+
return c.json({ ok: true });
|
|
1696
|
+
});
|
|
1697
|
+
/**
|
|
1698
|
+
* v0.7 - Terminal registry endpoints for the VS Code extension.
|
|
1699
|
+
*
|
|
1700
|
+
* The extension POSTs these when the user's VS Code terminals open, get
|
|
1701
|
+
* renamed, or close. We store the mapping shell_pid → tab_name in memory
|
|
1702
|
+
* so the correlator (see watcher.ts — landing in v0.7.1) can auto-alias
|
|
1703
|
+
* new Claude Code sessions with the tab name they were started in.
|
|
1704
|
+
*
|
|
1705
|
+
* All three endpoints accept small JSON bodies and return `{ ok: true,
|
|
1706
|
+
* count }`. They never read or write anything outside the in-memory map.
|
|
1707
|
+
*/
|
|
1708
|
+
app.post('/api/terminal/opened', async (c) => {
|
|
1709
|
+
const body = (await c.req.json().catch(() => null));
|
|
1710
|
+
if (!body || typeof body.shell_pid !== 'number' || typeof body.tab_name !== 'string') {
|
|
1711
|
+
return c.json({ error: 'shell_pid and tab_name required' }, 400);
|
|
1712
|
+
}
|
|
1713
|
+
const entry = terminalRegistry.upsert({
|
|
1714
|
+
shell_pid: body.shell_pid,
|
|
1715
|
+
tab_name: body.tab_name,
|
|
1716
|
+
cwd: body.cwd ?? null,
|
|
1717
|
+
opened_at: body.opened_at ?? new Date().toISOString(),
|
|
1718
|
+
});
|
|
1719
|
+
return c.json({ ok: true, count: terminalRegistry.size(), entry });
|
|
1720
|
+
});
|
|
1721
|
+
app.post('/api/terminal/renamed', async (c) => {
|
|
1722
|
+
const body = (await c.req.json().catch(() => null));
|
|
1723
|
+
if (!body || typeof body.shell_pid !== 'number' || typeof body.tab_name !== 'string') {
|
|
1724
|
+
return c.json({ error: 'shell_pid and tab_name required' }, 400);
|
|
1725
|
+
}
|
|
1726
|
+
const entry = terminalRegistry.rename(body.shell_pid, body.tab_name);
|
|
1727
|
+
if (!entry)
|
|
1728
|
+
return c.json({ error: 'unknown shell_pid' }, 404);
|
|
1729
|
+
const propagated = propagateRenameToSessions(body.shell_pid, body.tab_name);
|
|
1730
|
+
return c.json({ ok: true, entry, propagated });
|
|
1731
|
+
});
|
|
1732
|
+
app.post('/api/terminal/closed', async (c) => {
|
|
1733
|
+
const body = (await c.req.json().catch(() => null));
|
|
1734
|
+
if (!body || typeof body.shell_pid !== 'number') {
|
|
1735
|
+
return c.json({ error: 'shell_pid required' }, 400);
|
|
1736
|
+
}
|
|
1737
|
+
const removed = terminalRegistry.remove(body.shell_pid);
|
|
1738
|
+
return c.json({ ok: true, removed, count: terminalRegistry.size() });
|
|
1739
|
+
});
|
|
1740
|
+
/**
|
|
1741
|
+
* Deterministic-link breadcrumb. The VS Code extension fires
|
|
1742
|
+
* `onDidStartTerminalShellExecution` when the user runs `claude` and POSTs
|
|
1743
|
+
* the (shell_pid, tab_name, cwd, started_at) tuple here. The correlator
|
|
1744
|
+
* drains the most recent matching cwd within ~30s of the JSONL appearing
|
|
1745
|
+
* on disk. This replaces the lsof + ppid-walk guesswork that previously
|
|
1746
|
+
* mis-linked sessions to sibling terminals' shell_pids.
|
|
1747
|
+
*/
|
|
1748
|
+
app.post('/api/terminal/claude-started', async (c) => {
|
|
1749
|
+
const body = (await c.req.json().catch(() => null));
|
|
1750
|
+
if (!body || typeof body.shell_pid !== 'number') {
|
|
1751
|
+
return c.json({ error: 'shell_pid required' }, 400);
|
|
1752
|
+
}
|
|
1753
|
+
terminalRegistry.pushPending({
|
|
1754
|
+
shell_pid: body.shell_pid,
|
|
1755
|
+
tab_name: typeof body.tab_name === 'string' ? body.tab_name : '',
|
|
1756
|
+
cwd: typeof body.cwd === 'string' ? body.cwd : null,
|
|
1757
|
+
started_at: typeof body.started_at === 'string' ? body.started_at : new Date().toISOString(),
|
|
1758
|
+
});
|
|
1759
|
+
return c.json({ ok: true, pending: terminalRegistry.pendingSize() });
|
|
1760
|
+
});
|
|
1761
|
+
/**
|
|
1762
|
+
* Full-snapshot reconcile. The extension sends the complete list of open
|
|
1763
|
+
* terminals on a polling loop (every few seconds). The registry replaces
|
|
1764
|
+
* itself with this list and, for any shell_pid whose tab_name changed,
|
|
1765
|
+
* propagates the new name to every session that started in that shell —
|
|
1766
|
+
* so a rename in VS Code shows up as the session alias in Claude Recall
|
|
1767
|
+
* without any manual step.
|
|
1768
|
+
*/
|
|
1769
|
+
app.post('/api/terminal/sync', async (c) => {
|
|
1770
|
+
const body = (await c.req.json().catch(() => null));
|
|
1771
|
+
if (!body || !Array.isArray(body.terminals)) {
|
|
1772
|
+
return c.json({ error: 'terminals array required' }, 400);
|
|
1773
|
+
}
|
|
1774
|
+
// Capture previous tab names so we can detect renames post-reconcile.
|
|
1775
|
+
const previous = new Map();
|
|
1776
|
+
for (const t of terminalRegistry.all())
|
|
1777
|
+
previous.set(t.shell_pid, t.tab_name);
|
|
1778
|
+
const snapshot = body.terminals
|
|
1779
|
+
.filter((t) => !!t && typeof t.shell_pid === 'number' && typeof t.tab_name === 'string')
|
|
1780
|
+
.map((t) => ({
|
|
1781
|
+
shell_pid: t.shell_pid,
|
|
1782
|
+
tab_name: t.tab_name,
|
|
1783
|
+
cwd: t.cwd ?? null,
|
|
1784
|
+
opened_at: t.opened_at ?? new Date().toISOString(),
|
|
1785
|
+
}));
|
|
1786
|
+
const diff = terminalRegistry.sync(snapshot);
|
|
1787
|
+
// Propagate any rename we see. The registry may reject a Claude Code
|
|
1788
|
+
// auto-title for storage purposes (to keep a user-set name pinned), but
|
|
1789
|
+
// for PROPAGATION we want to consider both: the registry's stored name
|
|
1790
|
+
// (good when the user typed a real name) AND the raw incoming name
|
|
1791
|
+
// (good when Claude generated a session title that strips to clean).
|
|
1792
|
+
// propagateRenameToSessions handles the strip + clean check.
|
|
1793
|
+
let propagatedTotal = 0;
|
|
1794
|
+
for (const t of snapshot) {
|
|
1795
|
+
const prev = previous.get(t.shell_pid);
|
|
1796
|
+
const stored = terminalRegistry.get(t.shell_pid)?.tab_name ?? t.tab_name;
|
|
1797
|
+
// Pick the candidate most likely to produce a useful alias: the stored
|
|
1798
|
+
// name when it's a real user-typed name, otherwise the raw incoming
|
|
1799
|
+
// (which propagation will strip if it's a Claude auto-title).
|
|
1800
|
+
const storedLooksClean = !!stored && !isGenericShellName(stored) && !looksLikeClaudeAutoTitle(stored);
|
|
1801
|
+
const candidate = storedLooksClean ? stored : t.tab_name;
|
|
1802
|
+
if (prev !== undefined && prev !== candidate) {
|
|
1803
|
+
propagatedTotal += propagateRenameToSessions(t.shell_pid, candidate);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
return c.json({
|
|
1807
|
+
ok: true,
|
|
1808
|
+
count: terminalRegistry.size(),
|
|
1809
|
+
diff,
|
|
1810
|
+
propagated: propagatedTotal,
|
|
1811
|
+
});
|
|
1812
|
+
});
|
|
1813
|
+
app.get('/api/terminal/registry', (c) => {
|
|
1814
|
+
return c.json({
|
|
1815
|
+
terminals: terminalRegistry.all(),
|
|
1816
|
+
count: terminalRegistry.size(),
|
|
1817
|
+
});
|
|
1818
|
+
});
|
|
1819
|
+
/**
|
|
1820
|
+
* Force-rerun the correlator for a single session. Unlinks any existing
|
|
1821
|
+
* shell-pid association, clears the auto alias, and calls tryAutoAlias
|
|
1822
|
+
* again so the session picks up the current registry state. Intended for
|
|
1823
|
+
* sessions that were aliased under an old correlator version, or when a
|
|
1824
|
+
* user has opened a fresh terminal AFTER the session was indexed and
|
|
1825
|
+
* wants the alias tied to that new terminal.
|
|
1826
|
+
*/
|
|
1827
|
+
app.post('/api/sessions/:id/recorrelate', async (c) => {
|
|
1828
|
+
const id = c.req.param('id');
|
|
1829
|
+
const row = getDb()
|
|
1830
|
+
.prepare('SELECT file_path FROM sessions WHERE id = ?')
|
|
1831
|
+
.get(id);
|
|
1832
|
+
if (!row?.file_path)
|
|
1833
|
+
return c.json({ error: 'session not found' }, 404);
|
|
1834
|
+
terminalRegistry.unlinkSession(id);
|
|
1835
|
+
clearAlias(id);
|
|
1836
|
+
await tryAutoAlias(row.file_path);
|
|
1837
|
+
const newAlias = getAlias(id);
|
|
1838
|
+
return c.json({
|
|
1839
|
+
ok: true,
|
|
1840
|
+
alias: newAlias,
|
|
1841
|
+
linked_pid: terminalRegistry
|
|
1842
|
+
.all()
|
|
1843
|
+
.find((t) => terminalRegistry.sessionsFor(t.shell_pid).includes(id))
|
|
1844
|
+
?.shell_pid ?? null,
|
|
1845
|
+
});
|
|
1846
|
+
});
|
|
1847
|
+
/**
|
|
1848
|
+
* v0.4.5 — Paste expand.
|
|
1849
|
+
* A message whose content is `[Pasted text #N +L lines] <path>` stores only
|
|
1850
|
+
* a placeholder. This endpoint attempts two recoveries:
|
|
1851
|
+
* 1. Scan the *next* ~10 messages in the same session for a Read tool
|
|
1852
|
+
* result that contains the path — if found, return that content.
|
|
1853
|
+
* 2. Fall back to reading the file from disk.
|
|
1854
|
+
*
|
|
1855
|
+
* Security: we refuse any path that doesn't appear in an actual paste
|
|
1856
|
+
* placeholder in an indexed message in this session. This is an
|
|
1857
|
+
* "allowlist via observation" — we only serve files the user themselves
|
|
1858
|
+
* already referenced in a prior session.
|
|
1859
|
+
*/
|
|
1860
|
+
app.get('/api/paste-expand', async (c) => {
|
|
1861
|
+
const sessionId = c.req.query('session');
|
|
1862
|
+
const messageUuid = c.req.query('message');
|
|
1863
|
+
const path = c.req.query('path');
|
|
1864
|
+
if (!sessionId || !messageUuid || !path) {
|
|
1865
|
+
return c.json({ error: 'session, message and path are required' }, 400);
|
|
1866
|
+
}
|
|
1867
|
+
const db = getDb();
|
|
1868
|
+
const msg = db
|
|
1869
|
+
.prepare('SELECT rowid, content_text FROM messages WHERE uuid = ? AND session_id = ?')
|
|
1870
|
+
.get(messageUuid, sessionId);
|
|
1871
|
+
if (!msg)
|
|
1872
|
+
return c.json({ error: 'message not found in session' }, 404);
|
|
1873
|
+
// The placeholder pattern. Path must be literally in the content.
|
|
1874
|
+
const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1875
|
+
const allowlistRe = new RegExp(`\\[Pasted text #\\d+ \\+\\d+ lines\\]\\s*${escaped}`);
|
|
1876
|
+
if (!allowlistRe.test(msg.content_text ?? '')) {
|
|
1877
|
+
return c.json({ error: 'path not referenced by this message' }, 403);
|
|
1878
|
+
}
|
|
1879
|
+
// 1) search next messages for a Read tool result containing this file's content
|
|
1880
|
+
const nearby = db
|
|
1881
|
+
.prepare(`SELECT content_text FROM messages
|
|
1882
|
+
WHERE session_id = ? AND rowid > ?
|
|
1883
|
+
ORDER BY rowid ASC LIMIT 10`)
|
|
1884
|
+
.all(sessionId, msg.rowid);
|
|
1885
|
+
for (const m of nearby) {
|
|
1886
|
+
const body = m.content_text ?? '';
|
|
1887
|
+
// Tool results are rendered with a "**Tool result**\n```\n…\n```" wrapper.
|
|
1888
|
+
if (body.includes('**Tool result**') && body.includes(path)) {
|
|
1889
|
+
return c.json({ source: 'tool-result', content: body });
|
|
1890
|
+
}
|
|
1891
|
+
// Some Read results are numbered-line format; also accept heuristic match.
|
|
1892
|
+
if (/^\s*1\t/.test(body) && body.length > 200) {
|
|
1893
|
+
return c.json({ source: 'tool-result', content: body });
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
// 2) fall back to disk read, bounded at 2 MB.
|
|
1897
|
+
//
|
|
1898
|
+
// Path-traversal defense: even though the allowlist regex above ensures
|
|
1899
|
+
// the path appears in an indexed paste placeholder, a crafted session
|
|
1900
|
+
// message could contain `../../etc/passwd`. Resolve with realpath() (so
|
|
1901
|
+
// symlinks are followed to their real target) then require the resolved
|
|
1902
|
+
// path to live under $HOME. That contains the blast radius to the user's
|
|
1903
|
+
// own files — never /etc, /var, another user's home, etc.
|
|
1904
|
+
try {
|
|
1905
|
+
const real = await realpath(path);
|
|
1906
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
|
|
1907
|
+
if (!home || (!real.startsWith(home + '/') && !real.startsWith(home + '\\'))) {
|
|
1908
|
+
return c.json({ error: 'path outside allowed root' }, 403);
|
|
1909
|
+
}
|
|
1910
|
+
const st = await stat(real);
|
|
1911
|
+
const MAX = 2 * 1024 * 1024;
|
|
1912
|
+
if (st.size > MAX) {
|
|
1913
|
+
return c.json({ error: 'file too large', size: st.size, max: MAX }, 413);
|
|
1914
|
+
}
|
|
1915
|
+
const content = await readFile(real, 'utf8');
|
|
1916
|
+
return c.json({ source: 'disk', content });
|
|
1917
|
+
}
|
|
1918
|
+
catch (err) {
|
|
1919
|
+
return c.json({ source: 'missing', error: err.message });
|
|
1920
|
+
}
|
|
1921
|
+
});
|
|
1922
|
+
// Per-project stats — for the "project summary" card above the sessions list.
|
|
1923
|
+
app.get('/api/projects/:name/stats', (c) => {
|
|
1924
|
+
const db = getDb();
|
|
1925
|
+
const name = c.req.param('name');
|
|
1926
|
+
const summary = db
|
|
1927
|
+
.prepare(`SELECT
|
|
1928
|
+
(SELECT COUNT(*) FROM sessions s JOIN projects p ON p.id=s.project_id WHERE p.name=? AND s.message_count > 2) AS sessions,
|
|
1929
|
+
(SELECT COALESCE(SUM(s.message_count), 0) FROM sessions s JOIN projects p ON p.id=s.project_id WHERE p.name=?) AS messages,
|
|
1930
|
+
(SELECT MIN(s.started_at) FROM sessions s JOIN projects p ON p.id=s.project_id WHERE p.name=? AND s.started_at IS NOT NULL) AS earliest,
|
|
1931
|
+
(SELECT MAX(COALESCE(s.ended_at, s.started_at)) FROM sessions s JOIN projects p ON p.id=s.project_id WHERE (s.ended_at IS NOT NULL OR s.started_at IS NOT NULL) AND p.name=?) AS latest`)
|
|
1932
|
+
.get(name, name, name, name);
|
|
1933
|
+
const branches = db
|
|
1934
|
+
.prepare(`SELECT DISTINCT s.git_branch FROM sessions s
|
|
1935
|
+
JOIN projects p ON p.id = s.project_id
|
|
1936
|
+
WHERE p.name = ? AND s.git_branch IS NOT NULL
|
|
1937
|
+
ORDER BY s.git_branch
|
|
1938
|
+
LIMIT 20`)
|
|
1939
|
+
.all(name)
|
|
1940
|
+
.map((r) => r.git_branch);
|
|
1941
|
+
return c.json({ ...summary, branches });
|
|
1942
|
+
});
|
|
1943
|
+
// --- Static UI ---
|
|
1944
|
+
if (HAS_BUNDLED_UI) {
|
|
1945
|
+
app.use('/assets/*', serveStatic({ root: DIST_WEB }));
|
|
1946
|
+
app.get('/favicon.svg', serveStatic({ root: DIST_WEB }));
|
|
1947
|
+
// The HTML itself must NEVER cache — it's what references the
|
|
1948
|
+
// hashed JS/CSS bundles. If the HTML caches, the browser keeps
|
|
1949
|
+
// asking for a stale bundle hash even after rebuilds.
|
|
1950
|
+
app.get('/', (c) => {
|
|
1951
|
+
c.header('cache-control', 'no-cache, no-store, must-revalidate');
|
|
1952
|
+
c.header('pragma', 'no-cache');
|
|
1953
|
+
c.header('expires', '0');
|
|
1954
|
+
return c.html(readFileSync(INDEX_HTML, 'utf8'));
|
|
1955
|
+
});
|
|
1956
|
+
app.get('*', (c) => {
|
|
1957
|
+
if (c.req.path.startsWith('/api/'))
|
|
1958
|
+
return c.notFound();
|
|
1959
|
+
c.header('cache-control', 'no-cache, no-store, must-revalidate');
|
|
1960
|
+
c.header('pragma', 'no-cache');
|
|
1961
|
+
c.header('expires', '0');
|
|
1962
|
+
return c.html(readFileSync(INDEX_HTML, 'utf8'));
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
else {
|
|
1966
|
+
app.get('/', (c) => {
|
|
1967
|
+
const stats = readStats();
|
|
1968
|
+
return c.html(placeholderHtml({
|
|
1969
|
+
projects: stats.projects,
|
|
1970
|
+
sessions: stats.sessions,
|
|
1971
|
+
messages: stats.messages,
|
|
1972
|
+
port: Number(c.req.raw.headers.get('host')?.split(':')[1] ?? 0),
|
|
1973
|
+
version: VERSION,
|
|
1974
|
+
}));
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
return app;
|
|
1978
|
+
}
|
|
1979
|
+
export async function startServer(port) {
|
|
1980
|
+
const app = buildApp();
|
|
1981
|
+
return new Promise((resolve, reject) => {
|
|
1982
|
+
try {
|
|
1983
|
+
const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
|
|
1984
|
+
// Kick autopilot once on boot. No-op unless the user has already
|
|
1985
|
+
// enabled it + set an API key. Idempotent — kickAutopilot guards
|
|
1986
|
+
// against double-starts internally.
|
|
1987
|
+
void kickAutopilot();
|
|
1988
|
+
// v0.14b (T5) — one-time heuristic backfill. Cheap (SQL NULL check
|
|
1989
|
+
// makes repeat boots a no-op after the first fill), so we just run
|
|
1990
|
+
// it every startup instead of tracking a "has-run" flag.
|
|
1991
|
+
if (readAutoTitleConfig().heuristicEnabled) {
|
|
1992
|
+
try {
|
|
1993
|
+
const { updated } = backfillHeuristicTitles();
|
|
1994
|
+
if (updated > 0) {
|
|
1995
|
+
console.log(`[auto-title] backfilled heuristic title on ${updated} sessions`);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
catch (err) {
|
|
1999
|
+
console.error('[auto-title] backfill failed:', err);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
resolve(server);
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
catch (err) {
|
|
2006
|
+
reject(err);
|
|
2007
|
+
}
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
//# sourceMappingURL=server.js.map
|