@zzusp/ccsm 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/bin/cli.mjs +52 -0
- package/dist/assets/DiskUsage-Bq4VaoUA.js +2 -0
- package/dist/assets/DiskUsage-Bq4VaoUA.js.map +1 -0
- package/dist/assets/ImportPage-b8NORa8b.js +2 -0
- package/dist/assets/ImportPage-b8NORa8b.js.map +1 -0
- package/dist/assets/ProjectMemory-aSV8UzQ9.js +2 -0
- package/dist/assets/ProjectMemory-aSV8UzQ9.js.map +1 -0
- package/dist/assets/charts-A5eNHLjX.js +56 -0
- package/dist/assets/charts-A5eNHLjX.js.map +1 -0
- package/dist/assets/geist-mono-cyrillic-wght-normal-BZdD_g9V.woff2 +0 -0
- package/dist/assets/geist-mono-latin-ext-wght-normal-b6lpi8_2.woff2 +0 -0
- package/dist/assets/geist-mono-latin-wght-normal-Cjtb1TV-.woff2 +0 -0
- package/dist/assets/index-DLATR3tZ.js +5 -0
- package/dist/assets/index-DLATR3tZ.js.map +1 -0
- package/dist/assets/index-DLDtbkux.css +1 -0
- package/dist/assets/plus-jakarta-sans-latin-ext-wght-italic-DJWiFoht.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-wght-italic-DnD1KgkH.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-vietnamese-wght-italic-CPBsCcxN.woff2 +0 -0
- package/dist/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
- package/dist/assets/query-C1K1uQRu.js +2 -0
- package/dist/assets/query-C1K1uQRu.js.map +1 -0
- package/dist/assets/react-W0jzChlo.js +50 -0
- package/dist/assets/react-W0jzChlo.js.map +1 -0
- package/dist/assets/router-DfbutHY3.js +13 -0
- package/dist/assets/router-DfbutHY3.js.map +1 -0
- package/dist/assets/vendor-CH80ylbS.js +19 -0
- package/dist/assets/vendor-CH80ylbS.js.map +1 -0
- package/dist/favicon.svg +7 -0
- package/dist/index.html +30 -0
- package/package.json +72 -0
- package/server/index.ts +126 -0
- package/server/lib/active-sessions.ts +95 -0
- package/server/lib/bundle.ts +86 -0
- package/server/lib/claude-paths.ts +36 -0
- package/server/lib/constants.ts +7 -0
- package/server/lib/delete-project.ts +100 -0
- package/server/lib/delete.ts +203 -0
- package/server/lib/disk-usage.ts +83 -0
- package/server/lib/encode-cwd.ts +24 -0
- package/server/lib/export-bundle.ts +236 -0
- package/server/lib/fs-size.ts +38 -0
- package/server/lib/import-bundle.ts +488 -0
- package/server/lib/load-memory.ts +120 -0
- package/server/lib/load-session.ts +209 -0
- package/server/lib/open-folder.ts +40 -0
- package/server/lib/parse-jsonl.ts +107 -0
- package/server/lib/port.ts +23 -0
- package/server/lib/rename-session.ts +0 -0
- package/server/lib/safe-id.ts +6 -0
- package/server/lib/scan.ts +183 -0
- package/server/lib/search-all.ts +130 -0
- package/server/lib/search-session.ts +203 -0
- package/server/lib/system-tags.ts +20 -0
- package/server/routes/disk.ts +9 -0
- package/server/routes/import.ts +87 -0
- package/server/routes/projects.ts +104 -0
- package/server/routes/search.ts +79 -0
- package/server/routes/sessions.ts +81 -0
- package/server/types.ts +1 -0
- package/shared/constants.ts +2 -0
- package/shared/types.ts +359 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import type { SearchBlockKind, SearchSnippet } from '../types.ts';
|
|
4
|
+
|
|
5
|
+
export interface SearchSessionResult {
|
|
6
|
+
snippets: SearchSnippet[];
|
|
7
|
+
hasMore: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SearchSessionOpts {
|
|
11
|
+
include: ReadonlySet<SearchBlockKind>;
|
|
12
|
+
perSession: number;
|
|
13
|
+
windowChars?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_WINDOW = 60;
|
|
17
|
+
const ADJACENT_GAP = 120;
|
|
18
|
+
|
|
19
|
+
export async function searchSessionFile(
|
|
20
|
+
filePath: string,
|
|
21
|
+
pattern: string,
|
|
22
|
+
opts: SearchSessionOpts,
|
|
23
|
+
): Promise<SearchSessionResult> {
|
|
24
|
+
const windowChars = opts.windowChars ?? DEFAULT_WINDOW;
|
|
25
|
+
const snippets: SearchSnippet[] = [];
|
|
26
|
+
let hasMore = false;
|
|
27
|
+
|
|
28
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
29
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
for await (const raw of rl) {
|
|
33
|
+
if (snippets.length >= opts.perSession) {
|
|
34
|
+
hasMore = true;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
const line = raw.trim();
|
|
38
|
+
if (!line) continue;
|
|
39
|
+
|
|
40
|
+
let obj: Record<string, unknown>;
|
|
41
|
+
try {
|
|
42
|
+
obj = JSON.parse(line) as Record<string, unknown>;
|
|
43
|
+
} catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') continue;
|
|
48
|
+
const role = obj.type;
|
|
49
|
+
const uuid = typeof obj.uuid === 'string' ? obj.uuid : '';
|
|
50
|
+
if (!uuid) continue;
|
|
51
|
+
const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
|
|
52
|
+
const message = (obj.message ?? {}) as { content?: unknown };
|
|
53
|
+
|
|
54
|
+
const blocks = extractSearchableBlocks(message.content, opts.include);
|
|
55
|
+
for (const block of blocks) {
|
|
56
|
+
if (snippets.length >= opts.perSession) {
|
|
57
|
+
hasMore = true;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
const matches = findMatches(block.text, pattern);
|
|
61
|
+
for (const match of matches) {
|
|
62
|
+
if (snippets.length >= opts.perSession) {
|
|
63
|
+
hasMore = true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
snippets.push({
|
|
67
|
+
uuid,
|
|
68
|
+
ts,
|
|
69
|
+
role,
|
|
70
|
+
blockKind: block.kind,
|
|
71
|
+
...sliceWindow(block.text, match.start, match.end, windowChars),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
rl.close();
|
|
78
|
+
stream.destroy();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { snippets, hasMore };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface SearchableBlock {
|
|
85
|
+
kind: SearchBlockKind;
|
|
86
|
+
text: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractSearchableBlocks(
|
|
90
|
+
content: unknown,
|
|
91
|
+
include: ReadonlySet<SearchBlockKind>,
|
|
92
|
+
): SearchableBlock[] {
|
|
93
|
+
if (typeof content === 'string') {
|
|
94
|
+
return include.has('text') && content ? [{ kind: 'text', text: content }] : [];
|
|
95
|
+
}
|
|
96
|
+
if (!Array.isArray(content)) return [];
|
|
97
|
+
|
|
98
|
+
const out: SearchableBlock[] = [];
|
|
99
|
+
for (const raw of content) {
|
|
100
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
101
|
+
const b = raw as Record<string, unknown>;
|
|
102
|
+
switch (b.type) {
|
|
103
|
+
case 'text':
|
|
104
|
+
if (include.has('text') && typeof b.text === 'string' && b.text) {
|
|
105
|
+
out.push({ kind: 'text', text: b.text });
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
case 'tool_use':
|
|
109
|
+
if (include.has('tool_use')) {
|
|
110
|
+
const name = typeof b.name === 'string' ? b.name : '';
|
|
111
|
+
let inputStr = '';
|
|
112
|
+
try {
|
|
113
|
+
inputStr = b.input == null ? '' : JSON.stringify(b.input);
|
|
114
|
+
} catch {
|
|
115
|
+
inputStr = '';
|
|
116
|
+
}
|
|
117
|
+
const text = inputStr ? `${name} ${inputStr}` : name;
|
|
118
|
+
if (text) out.push({ kind: 'tool_use', text });
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
case 'tool_result':
|
|
122
|
+
if (include.has('tool_result')) {
|
|
123
|
+
const text = stringifyToolResult(b.content);
|
|
124
|
+
if (text) out.push({ kind: 'tool_result', text });
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
case 'thinking':
|
|
128
|
+
if (include.has('thinking') && typeof b.thinking === 'string' && b.thinking) {
|
|
129
|
+
out.push({ kind: 'thinking', text: b.thinking });
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stringifyToolResult(content: unknown): string {
|
|
138
|
+
if (typeof content === 'string') return content;
|
|
139
|
+
if (Array.isArray(content)) {
|
|
140
|
+
return content
|
|
141
|
+
.map((b) => {
|
|
142
|
+
if (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text') {
|
|
143
|
+
const t = (b as { text?: unknown }).text;
|
|
144
|
+
return typeof t === 'string' ? t : '';
|
|
145
|
+
}
|
|
146
|
+
return '';
|
|
147
|
+
})
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.join('\n');
|
|
150
|
+
}
|
|
151
|
+
return '';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface MatchPos {
|
|
155
|
+
start: number;
|
|
156
|
+
end: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function findMatches(text: string, pattern: string): MatchPos[] {
|
|
160
|
+
const out: MatchPos[] = [];
|
|
161
|
+
if (!pattern) return out;
|
|
162
|
+
const lower = text.toLowerCase();
|
|
163
|
+
const needle = pattern.toLowerCase();
|
|
164
|
+
let from = 0;
|
|
165
|
+
let lastEnd = -ADJACENT_GAP - 1;
|
|
166
|
+
while (from <= lower.length) {
|
|
167
|
+
const idx = lower.indexOf(needle, from);
|
|
168
|
+
if (idx === -1) break;
|
|
169
|
+
const end = idx + needle.length;
|
|
170
|
+
if (idx - lastEnd >= ADJACENT_GAP) {
|
|
171
|
+
out.push({ start: idx, end });
|
|
172
|
+
lastEnd = end;
|
|
173
|
+
}
|
|
174
|
+
from = end > from ? end : from + 1;
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function sliceWindow(
|
|
180
|
+
text: string,
|
|
181
|
+
start: number,
|
|
182
|
+
end: number,
|
|
183
|
+
windowChars: number,
|
|
184
|
+
): { before: string; match: string; after: string } {
|
|
185
|
+
let beforeStart = Math.max(0, start - windowChars);
|
|
186
|
+
let afterEnd = Math.min(text.length, end + windowChars);
|
|
187
|
+
|
|
188
|
+
if (beforeStart > 0) {
|
|
189
|
+
const ws = text.lastIndexOf(' ', beforeStart);
|
|
190
|
+
if (ws !== -1 && start - ws < windowChars * 2) beforeStart = ws + 1;
|
|
191
|
+
}
|
|
192
|
+
if (afterEnd < text.length) {
|
|
193
|
+
const ws = text.indexOf(' ', afterEnd);
|
|
194
|
+
if (ws !== -1 && ws - end < windowChars * 2) afterEnd = ws;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let before = text.slice(beforeStart, start).replace(/\s+/g, ' ');
|
|
198
|
+
let after = text.slice(end, afterEnd).replace(/\s+/g, ' ');
|
|
199
|
+
if (beforeStart > 0) before = '… ' + before;
|
|
200
|
+
if (afterEnd < text.length) after = after + ' …';
|
|
201
|
+
|
|
202
|
+
return { before, match: text.slice(start, end), after };
|
|
203
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Matches text that is purely system-injected wrapper content — local command
|
|
2
|
+
// stdout/stderr replays, runtime system-reminder blocks, and caveat banners.
|
|
3
|
+
// Slash-command invocations (`<command-name>` / `<command-message>` /
|
|
4
|
+
// `<command-args>`) are user-driven and excluded — their `<command-args>` body
|
|
5
|
+
// carries the user's actual prompt.
|
|
6
|
+
export const SYSTEM_TAG_RE = /^\s*<(local-command|system-reminder|caveat)/i;
|
|
7
|
+
|
|
8
|
+
// Slash-command records carry the user's actual prompt (if any) inside
|
|
9
|
+
// <command-args>BODY</command-args>. Returns the trimmed args body when
|
|
10
|
+
// meaningful; returns '' when the record is just a metadata invocation
|
|
11
|
+
// (/clear, /model, /login with empty args, or legacy shapes that lack a
|
|
12
|
+
// <command-args> tag entirely) so callers can skip the message. Returns the
|
|
13
|
+
// input unchanged for non-slash-command text. `claude resume` applies the
|
|
14
|
+
// same skip when picking its picker labels — without it, titles for older
|
|
15
|
+
// sessions fall on raw XML wrapper text.
|
|
16
|
+
export function pickTitleText(text: string): string {
|
|
17
|
+
if (!/^\s*<command-(?:name|message|args)>/.test(text)) return text;
|
|
18
|
+
const m = text.match(/<command-args>([\s\S]*?)<\/command-args>/);
|
|
19
|
+
return (m?.[1] ?? '').trim();
|
|
20
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { commitImport, ImportError, previewImport } from '../lib/import-bundle.ts';
|
|
3
|
+
import type { ImportCollisionPolicy } from '../types.ts';
|
|
4
|
+
|
|
5
|
+
export const importRoute = new Hono();
|
|
6
|
+
|
|
7
|
+
const POLICIES: ReadonlySet<ImportCollisionPolicy> = new Set([
|
|
8
|
+
'skip',
|
|
9
|
+
'overwrite-if-newer',
|
|
10
|
+
'keep-both',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
importRoute.post('/preview', async (c) => {
|
|
14
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
15
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
16
|
+
}
|
|
17
|
+
let body: { bundleDir?: unknown; targetCwd?: unknown; collisionPolicy?: unknown };
|
|
18
|
+
try {
|
|
19
|
+
body = await c.req.json();
|
|
20
|
+
} catch {
|
|
21
|
+
return c.json({ error: 'invalid JSON body' }, 400);
|
|
22
|
+
}
|
|
23
|
+
if (typeof body.bundleDir !== 'string' || body.bundleDir.trim() === '') {
|
|
24
|
+
return c.json({ error: 'bundleDir is required' }, 400);
|
|
25
|
+
}
|
|
26
|
+
const targetCwd =
|
|
27
|
+
typeof body.targetCwd === 'string' && body.targetCwd.trim() !== '' ? body.targetCwd : undefined;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const result = await previewImport({
|
|
31
|
+
bundleDir: body.bundleDir,
|
|
32
|
+
targetCwd,
|
|
33
|
+
collisionPolicy: normalizePolicy(body.collisionPolicy),
|
|
34
|
+
});
|
|
35
|
+
return c.json(result);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (err instanceof ImportError) return c.json({ error: err.message }, 400);
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
importRoute.post('/', async (c) => {
|
|
43
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
44
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
45
|
+
}
|
|
46
|
+
let body: { bundleDir?: unknown; targetCwd?: unknown; collisionPolicy?: unknown };
|
|
47
|
+
try {
|
|
48
|
+
body = await c.req.json();
|
|
49
|
+
} catch {
|
|
50
|
+
return c.json({ error: 'invalid JSON body' }, 400);
|
|
51
|
+
}
|
|
52
|
+
if (typeof body.bundleDir !== 'string' || body.bundleDir.trim() === '') {
|
|
53
|
+
return c.json({ error: 'bundleDir is required' }, 400);
|
|
54
|
+
}
|
|
55
|
+
if (typeof body.targetCwd !== 'string' || body.targetCwd.trim() === '') {
|
|
56
|
+
return c.json({ error: 'targetCwd is required' }, 400);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const result = await commitImport({
|
|
61
|
+
bundleDir: body.bundleDir,
|
|
62
|
+
targetCwd: body.targetCwd,
|
|
63
|
+
collisionPolicy: normalizePolicy(body.collisionPolicy),
|
|
64
|
+
});
|
|
65
|
+
return c.json(result);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err instanceof ImportError) return c.json({ error: err.message }, 400);
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function normalizePolicy(raw: unknown): ImportCollisionPolicy {
|
|
73
|
+
return typeof raw === 'string' && POLICIES.has(raw as ImportCollisionPolicy)
|
|
74
|
+
? (raw as ImportCollisionPolicy)
|
|
75
|
+
: 'skip';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isAcceptableOrigin(origin: string | undefined): boolean {
|
|
79
|
+
if (!origin) return false;
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(origin);
|
|
82
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
83
|
+
return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { deleteProject } from '../lib/delete-project.ts';
|
|
3
|
+
import { ExportError, exportBundle } from '../lib/export-bundle.ts';
|
|
4
|
+
import { loadProjectMemory } from '../lib/load-memory.ts';
|
|
5
|
+
import { openFolder } from '../lib/open-folder.ts';
|
|
6
|
+
import { isSafeId } from '../lib/safe-id.ts';
|
|
7
|
+
import { listProjects, listSessionsForProject, resolveProjectCwd } from '../lib/scan.ts';
|
|
8
|
+
|
|
9
|
+
export const projectsRoute = new Hono();
|
|
10
|
+
|
|
11
|
+
projectsRoute.get('/', async (c) => {
|
|
12
|
+
const projects = await listProjects();
|
|
13
|
+
return c.json(projects);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
projectsRoute.get('/:id/sessions', async (c) => {
|
|
17
|
+
const id = c.req.param('id');
|
|
18
|
+
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
19
|
+
const sessions = await listSessionsForProject(id);
|
|
20
|
+
return c.json(sessions);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
projectsRoute.get('/:id/memory', async (c) => {
|
|
24
|
+
const id = c.req.param('id');
|
|
25
|
+
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
26
|
+
const memory = await loadProjectMemory(id);
|
|
27
|
+
return c.json(memory);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
projectsRoute.post('/:id/reveal', async (c) => {
|
|
31
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
32
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
33
|
+
}
|
|
34
|
+
const id = c.req.param('id');
|
|
35
|
+
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
36
|
+
|
|
37
|
+
const cwd = await resolveProjectCwd(id);
|
|
38
|
+
if (!cwd) return c.json({ error: 'project not found' }, 404);
|
|
39
|
+
if (!cwd.resolved) return c.json({ error: 'directory missing on disk' }, 404);
|
|
40
|
+
|
|
41
|
+
const result = openFolder(cwd.decoded);
|
|
42
|
+
if (!result.ok) return c.json({ error: result.error ?? 'failed to open folder' }, 500);
|
|
43
|
+
|
|
44
|
+
return c.json({ ok: true, path: cwd.decoded });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
projectsRoute.post('/:id/export', async (c) => {
|
|
48
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
49
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
50
|
+
}
|
|
51
|
+
const id = c.req.param('id');
|
|
52
|
+
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
53
|
+
|
|
54
|
+
let body: { sessionIds?: unknown; destDir?: unknown };
|
|
55
|
+
try {
|
|
56
|
+
body = await c.req.json();
|
|
57
|
+
} catch {
|
|
58
|
+
return c.json({ error: 'invalid JSON body' }, 400);
|
|
59
|
+
}
|
|
60
|
+
if (typeof body.destDir !== 'string' || body.destDir.trim() === '') {
|
|
61
|
+
return c.json({ error: 'destDir is required' }, 400);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let sessionIds: string[] | 'all';
|
|
65
|
+
if (body.sessionIds === undefined || body.sessionIds === 'all') {
|
|
66
|
+
sessionIds = 'all';
|
|
67
|
+
} else if (
|
|
68
|
+
Array.isArray(body.sessionIds) &&
|
|
69
|
+
body.sessionIds.every((s) => typeof s === 'string')
|
|
70
|
+
) {
|
|
71
|
+
sessionIds = body.sessionIds as string[];
|
|
72
|
+
} else {
|
|
73
|
+
return c.json({ error: 'sessionIds must be an array of strings or "all"' }, 400);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const result = await exportBundle(id, sessionIds, body.destDir);
|
|
78
|
+
return c.json(result);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err instanceof ExportError) return c.json({ error: err.message }, 400);
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
projectsRoute.delete('/:id', async (c) => {
|
|
86
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
87
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
88
|
+
}
|
|
89
|
+
const id = c.req.param('id');
|
|
90
|
+
if (!isSafeId(id)) return c.json({ error: 'invalid project id' }, 400);
|
|
91
|
+
const result = await deleteProject(id);
|
|
92
|
+
return c.json(result);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function isAcceptableOrigin(origin: string | undefined): boolean {
|
|
96
|
+
if (!origin) return false;
|
|
97
|
+
try {
|
|
98
|
+
const url = new URL(origin);
|
|
99
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
100
|
+
return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { stream } from 'hono/streaming';
|
|
3
|
+
import { searchAll, type SearchAllOpts } from '../lib/search-all.ts';
|
|
4
|
+
import type { SearchBlockKind } from '../types.ts';
|
|
5
|
+
|
|
6
|
+
export const searchRoute = new Hono();
|
|
7
|
+
|
|
8
|
+
const ALL_KINDS: ReadonlyArray<SearchBlockKind> = [
|
|
9
|
+
'text',
|
|
10
|
+
'tool_use',
|
|
11
|
+
'tool_result',
|
|
12
|
+
'thinking',
|
|
13
|
+
];
|
|
14
|
+
const DEFAULT_INCLUDE: ReadonlySet<SearchBlockKind> = new Set([
|
|
15
|
+
'text',
|
|
16
|
+
'tool_use',
|
|
17
|
+
'thinking',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const Q_MIN = 2;
|
|
21
|
+
const Q_MAX = 200;
|
|
22
|
+
const PER_SESSION_MIN = 1;
|
|
23
|
+
const PER_SESSION_MAX = 20;
|
|
24
|
+
const PER_SESSION_DEFAULT = 5;
|
|
25
|
+
const MAX_SESSIONS_MIN = 1;
|
|
26
|
+
const MAX_SESSIONS_MAX = 200;
|
|
27
|
+
const MAX_SESSIONS_DEFAULT = 50;
|
|
28
|
+
|
|
29
|
+
searchRoute.get('/', async (c) => {
|
|
30
|
+
const q = c.req.query('q') ?? '';
|
|
31
|
+
if (q.length < Q_MIN) {
|
|
32
|
+
return c.json({ error: `q must be at least ${Q_MIN} characters` }, 400);
|
|
33
|
+
}
|
|
34
|
+
if (q.length > Q_MAX) {
|
|
35
|
+
return c.json({ error: `q exceeds max length ${Q_MAX}` }, 400);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const perSession = clampInt(c.req.query('perSession'), PER_SESSION_MIN, PER_SESSION_MAX, PER_SESSION_DEFAULT);
|
|
39
|
+
const maxSessions = clampInt(c.req.query('maxSessions'), MAX_SESSIONS_MIN, MAX_SESSIONS_MAX, MAX_SESSIONS_DEFAULT);
|
|
40
|
+
const include = parseInclude(c.req.query('include'));
|
|
41
|
+
|
|
42
|
+
const opts: SearchAllOpts = { query: q, include, perSession, maxSessions };
|
|
43
|
+
|
|
44
|
+
c.header('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
45
|
+
c.header('Cache-Control', 'no-store');
|
|
46
|
+
c.header('X-Accel-Buffering', 'no');
|
|
47
|
+
|
|
48
|
+
return stream(c, async (s) => {
|
|
49
|
+
let aborted = false;
|
|
50
|
+
s.onAbort(() => {
|
|
51
|
+
aborted = true;
|
|
52
|
+
});
|
|
53
|
+
for await (const event of searchAll(opts)) {
|
|
54
|
+
if (aborted) return;
|
|
55
|
+
await s.write(JSON.stringify(event) + '\n');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function clampInt(raw: string | undefined, min: number, max: number, fallback: number): number {
|
|
61
|
+
if (raw === undefined) return fallback;
|
|
62
|
+
const n = Number(raw);
|
|
63
|
+
if (!Number.isFinite(n) || !Number.isInteger(n)) return fallback;
|
|
64
|
+
if (n < min) return min;
|
|
65
|
+
if (n > max) return max;
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseInclude(raw: string | undefined): ReadonlySet<SearchBlockKind> {
|
|
70
|
+
if (!raw) return DEFAULT_INCLUDE;
|
|
71
|
+
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
72
|
+
const set = new Set<SearchBlockKind>();
|
|
73
|
+
for (const p of parts) {
|
|
74
|
+
if ((ALL_KINDS as readonly string[]).includes(p)) {
|
|
75
|
+
set.add(p as SearchBlockKind);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return set.size > 0 ? set : DEFAULT_INCLUDE;
|
|
79
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { deleteSessions, type DeleteRequestItem } from '../lib/delete.ts';
|
|
3
|
+
import { loadSessionDetail } from '../lib/load-session.ts';
|
|
4
|
+
import { renameSession } from '../lib/rename-session.ts';
|
|
5
|
+
import { isSafeId } from '../lib/safe-id.ts';
|
|
6
|
+
|
|
7
|
+
export const sessionsRoute = new Hono();
|
|
8
|
+
|
|
9
|
+
sessionsRoute.get('/:projectId/:sessionId', async (c) => {
|
|
10
|
+
const projectId = c.req.param('projectId');
|
|
11
|
+
const sessionId = c.req.param('sessionId');
|
|
12
|
+
if (!isSafeId(projectId) || !isSafeId(sessionId)) {
|
|
13
|
+
return c.json({ error: 'invalid id' }, 400);
|
|
14
|
+
}
|
|
15
|
+
const detail = await loadSessionDetail(projectId, sessionId);
|
|
16
|
+
if (!detail) return c.json({ error: 'not found' }, 404);
|
|
17
|
+
return c.json(detail);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
sessionsRoute.patch('/:projectId/:sessionId', async (c) => {
|
|
21
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
22
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
23
|
+
}
|
|
24
|
+
const projectId = c.req.param('projectId');
|
|
25
|
+
const sessionId = c.req.param('sessionId');
|
|
26
|
+
let body: { customTitle?: unknown };
|
|
27
|
+
try {
|
|
28
|
+
body = (await c.req.json()) as { customTitle?: unknown };
|
|
29
|
+
} catch {
|
|
30
|
+
return c.json({ error: 'invalid json body' }, 400);
|
|
31
|
+
}
|
|
32
|
+
if (typeof body.customTitle !== 'string') {
|
|
33
|
+
return c.json({ error: 'customTitle (string) required' }, 400);
|
|
34
|
+
}
|
|
35
|
+
const result = renameSession(projectId, sessionId, body.customTitle);
|
|
36
|
+
if (!result.ok) {
|
|
37
|
+
const status = result.reason.startsWith('live PID') ? 409 : 400;
|
|
38
|
+
return c.json({ error: result.reason }, status);
|
|
39
|
+
}
|
|
40
|
+
return c.json({ customTitle: result.customTitle });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
sessionsRoute.delete('/', async (c) => {
|
|
44
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
45
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
46
|
+
}
|
|
47
|
+
let body: { items?: unknown };
|
|
48
|
+
try {
|
|
49
|
+
body = (await c.req.json()) as { items?: unknown };
|
|
50
|
+
} catch {
|
|
51
|
+
return c.json({ error: 'invalid json body' }, 400);
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(body.items) || body.items.length === 0) {
|
|
54
|
+
return c.json({ error: 'items[] required' }, 400);
|
|
55
|
+
}
|
|
56
|
+
const items: DeleteRequestItem[] = [];
|
|
57
|
+
for (const raw of body.items) {
|
|
58
|
+
if (
|
|
59
|
+
!raw ||
|
|
60
|
+
typeof raw !== 'object' ||
|
|
61
|
+
typeof (raw as { projectId?: unknown }).projectId !== 'string' ||
|
|
62
|
+
typeof (raw as { sessionId?: unknown }).sessionId !== 'string'
|
|
63
|
+
) {
|
|
64
|
+
return c.json({ error: 'each item needs projectId and sessionId strings' }, 400);
|
|
65
|
+
}
|
|
66
|
+
items.push(raw as DeleteRequestItem);
|
|
67
|
+
}
|
|
68
|
+
const result = await deleteSessions(items);
|
|
69
|
+
return c.json(result);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function isAcceptableOrigin(origin: string | undefined): boolean {
|
|
73
|
+
if (!origin) return false;
|
|
74
|
+
try {
|
|
75
|
+
const url = new URL(origin);
|
|
76
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
77
|
+
return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
package/server/types.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '../shared/types.ts';
|