mulmoclaude 0.9.2 → 0.9.3
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/bin/mulmoclaude.js +2 -1
- package/client/assets/{index-Dc0R-HW5.js → index-40ErrJ4a.js} +23 -23
- package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
- package/client/assets/{marp-CSq0PPfK.js → marp-Dh7C24F1.js} +1 -1
- package/client/index.html +2 -2
- package/package.json +8 -7
- package/server/api/routes/chat-index.ts +5 -1
- package/server/api/routes/collections.ts +136 -0
- package/server/index.ts +14 -2
- package/server/prompts/system/system.md +7 -1
- package/server/remoteHost/handlers/collectionPage.ts +15 -17
- package/server/remoteHost/handlers/getCollection.ts +2 -2
- package/server/remoteHost/handlers/getFeed.ts +2 -2
- package/server/remoteHost/handlers/getRemoteView.ts +37 -0
- package/server/remoteHost/handlers/getRemoteViewItems.ts +38 -0
- package/server/remoteHost/handlers/index.ts +6 -0
- package/server/remoteHost/handlers/mutateRemoteView.ts +40 -0
- package/server/utils/files/thumbnail-store.ts +97 -0
- package/server/workspace/chat-index/index.ts +8 -1
- package/server/workspace/chat-index/indexer.ts +142 -27
- package/server/workspace/collections/index.ts +1 -0
- package/server/workspace/collections/remoteView.ts +290 -0
- package/src/App.vue +57 -3
- package/src/composables/collections/uiHost.ts +15 -0
- package/src/composables/usePubSub.ts +40 -2
- package/src/composables/useSessionSync.ts +9 -0
- package/src/config/apiRoutes.ts +20 -0
- package/src/config/roles.ts +2 -2
- package/src/lang/de.ts +2 -0
- package/src/lang/en.ts +2 -0
- package/src/lang/es.ts +2 -0
- package/src/lang/fr.ts +2 -0
- package/src/lang/ja.ts +2 -0
- package/src/lang/ko.ts +2 -0
- package/src/lang/pt-BR.ts +2 -0
- package/src/lang/zh.ts +1 -0
- package/src/plugins/textResponse/View.vue +49 -14
- package/src/plugins/textResponse/utils.ts +37 -0
- package/src/utils/html/previewCsp.ts +7 -16
- package/src/utils/role/icon.ts +9 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// getRemoteView command handler (remote-host phase 3 —
|
|
2
|
+
// plans/feat-remote-custom-view.md).
|
|
3
|
+
//
|
|
4
|
+
// Returns one mobile (`target: "mobile"`) custom view wrapped HOST-side into
|
|
5
|
+
// its sandboxed srcdoc (CSP + postMessage bootstrap), so the phone renders the
|
|
6
|
+
// artifact verbatim — the same builder the desktop phone-frame preview reads
|
|
7
|
+
// over HTTP, keeping preview === phone structural. The srcdoc travels inside
|
|
8
|
+
// the Firestore command document; the builder enforces the 1 MiB budget.
|
|
9
|
+
//
|
|
10
|
+
// Factory (createGetRemoteView) keeps the mapping unit-testable with the
|
|
11
|
+
// engine stubbed; the default export wires the real functions.
|
|
12
|
+
import { loadCollection } from "../../workspace/collections/index.js";
|
|
13
|
+
import { buildRemoteView, remoteViewFailureMessage } from "../../workspace/collections/remoteView.js";
|
|
14
|
+
import type { CommandHandler, JsonObject } from "../commandChannel.js";
|
|
15
|
+
|
|
16
|
+
export interface GetRemoteViewDeps {
|
|
17
|
+
loadCollection: typeof loadCollection;
|
|
18
|
+
buildRemoteView: typeof buildRemoteView;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const createGetRemoteView =
|
|
22
|
+
(deps: GetRemoteViewDeps): CommandHandler =>
|
|
23
|
+
async (params: JsonObject) => {
|
|
24
|
+
const slug = String(params.slug ?? "");
|
|
25
|
+
const viewId = String(params.viewId ?? "");
|
|
26
|
+
const locale = typeof params.locale === "string" ? params.locale : "";
|
|
27
|
+
const collection = await deps.loadCollection(slug);
|
|
28
|
+
if (!collection) throw new Error(`collection '${slug}' not found`);
|
|
29
|
+
const result = await deps.buildRemoteView(collection, viewId, locale);
|
|
30
|
+
if (result.kind !== "ok") throw new Error(remoteViewFailureMessage(result, slug));
|
|
31
|
+
const { view, srcdoc, bytes } = result;
|
|
32
|
+
// Plain JSON, but the interface lacks an index signature — cast like the
|
|
33
|
+
// phase-2 handlers.
|
|
34
|
+
return { view, srcdoc, bytes } as unknown as JsonObject;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const getRemoteView = createGetRemoteView({ loadCollection, buildRemoteView });
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// getRemoteViewItems command handler (remote-host phase 5 —
|
|
2
|
+
// plans/feat-remote-view-images.md).
|
|
3
|
+
//
|
|
4
|
+
// One page of a mobile view's records, view-aware so the host can inline the
|
|
5
|
+
// view's declared `imageFields` as `data:` URL thumbnails (a phone can't reach
|
|
6
|
+
// the workspace to render an image path). Same builder the desktop phone-frame
|
|
7
|
+
// preview reads over HTTP, so preview === phone. Supersedes the phase-2
|
|
8
|
+
// getCollection for a custom view's getItems: the page is already projected +
|
|
9
|
+
// image-inlined, so the mulmoserver client passes it straight to the view.
|
|
10
|
+
//
|
|
11
|
+
// Factory (createGetRemoteViewItems) keeps the mapping unit-testable with the
|
|
12
|
+
// engine stubbed; the default export wires the real functions.
|
|
13
|
+
import { clampLimit, clampOffset, normalizeFields } from "@mulmoclaude/core/remote-view";
|
|
14
|
+
import { loadCollection } from "../../workspace/collections/index.js";
|
|
15
|
+
import { remoteViewItems, remoteViewItemsFailureMessage } from "../../workspace/collections/remoteView.js";
|
|
16
|
+
import type { CommandHandler, JsonObject } from "../commandChannel.js";
|
|
17
|
+
|
|
18
|
+
export interface GetRemoteViewItemsDeps {
|
|
19
|
+
loadCollection: typeof loadCollection;
|
|
20
|
+
remoteViewItems: typeof remoteViewItems;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const createGetRemoteViewItems =
|
|
24
|
+
(deps: GetRemoteViewItemsDeps): CommandHandler =>
|
|
25
|
+
async (params: JsonObject) => {
|
|
26
|
+
const slug = String(params.slug ?? "");
|
|
27
|
+
const viewId = String(params.viewId ?? "");
|
|
28
|
+
const request = { offset: clampOffset(params.offset), limit: clampLimit(params.limit), fields: normalizeFields(params.fields) };
|
|
29
|
+
const collection = await deps.loadCollection(slug);
|
|
30
|
+
if (!collection) throw new Error(`collection '${slug}' not found`);
|
|
31
|
+
const result = await deps.remoteViewItems(collection, viewId, request);
|
|
32
|
+
if (result.kind !== "ok") throw new Error(remoteViewItemsFailureMessage(result, slug));
|
|
33
|
+
// Plain JSON, but the interface lacks an index signature — cast like the
|
|
34
|
+
// phase-2/3 handlers.
|
|
35
|
+
return { page: result.page, inlined: result.inlined, omitted: result.omitted } as unknown as JsonObject;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const getRemoteViewItems = createGetRemoteViewItems({ loadCollection, remoteViewItems });
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
import type { CommandHandlers } from "../commandChannel.js";
|
|
5
5
|
import { getCollection } from "./getCollection.js";
|
|
6
6
|
import { getFeed } from "./getFeed.js";
|
|
7
|
+
import { getRemoteView } from "./getRemoteView.js";
|
|
8
|
+
import { getRemoteViewItems } from "./getRemoteViewItems.js";
|
|
7
9
|
import { listCollections } from "./listCollections.js";
|
|
8
10
|
import { listFeeds } from "./listFeeds.js";
|
|
9
11
|
import { listShortcuts } from "./listShortcuts.js";
|
|
12
|
+
import { mutateRemoteViewItem } from "./mutateRemoteView.js";
|
|
10
13
|
import { startChat } from "./startChat.js";
|
|
11
14
|
|
|
12
15
|
export const handlers: CommandHandlers = {
|
|
@@ -15,5 +18,8 @@ export const handlers: CommandHandlers = {
|
|
|
15
18
|
listShortcuts,
|
|
16
19
|
listFeeds,
|
|
17
20
|
getFeed,
|
|
21
|
+
getRemoteView,
|
|
22
|
+
getRemoteViewItems,
|
|
23
|
+
mutateRemoteViewItem,
|
|
18
24
|
startChat,
|
|
19
25
|
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// mutateRemoteViewItem command handler (remote-host phase 4 —
|
|
2
|
+
// plans/feat-remote-writable-view.md).
|
|
3
|
+
//
|
|
4
|
+
// Applies one update/delete requested by a `target: "mobile"` custom view on
|
|
5
|
+
// the phone, authorized by that view's OWN declared surface
|
|
6
|
+
// (editableFields / allowDelete) and enforced HOST-side (createMutateRemoteView)
|
|
7
|
+
// — the sandboxed view is never trusted. The parent (mulmoserver) supplies the
|
|
8
|
+
// `viewId` it mounted; the sandboxed document cannot spoof a different view's
|
|
9
|
+
// policy. Shares the builder with the desktop preview's HTTP route so both
|
|
10
|
+
// transports apply identical policy.
|
|
11
|
+
//
|
|
12
|
+
// Factory (createMutateRemoteView-backed) keeps the mapping unit-testable with
|
|
13
|
+
// the engine stubbed; the default export wires the real functions.
|
|
14
|
+
import { normalizeMutate } from "@mulmoclaude/core/remote-view";
|
|
15
|
+
import { loadCollection } from "../../workspace/collections/index.js";
|
|
16
|
+
import { mutateRemoteView, mutateRemoteViewFailureMessage } from "../../workspace/collections/remoteView.js";
|
|
17
|
+
import type { CommandHandler, JsonObject } from "../commandChannel.js";
|
|
18
|
+
|
|
19
|
+
export interface MutateRemoteViewHandlerDeps {
|
|
20
|
+
loadCollection: typeof loadCollection;
|
|
21
|
+
mutateRemoteView: typeof mutateRemoteView;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const createMutateRemoteViewHandler =
|
|
25
|
+
(deps: MutateRemoteViewHandlerDeps): CommandHandler =>
|
|
26
|
+
async (params: JsonObject) => {
|
|
27
|
+
const slug = String(params.slug ?? "");
|
|
28
|
+
const viewId = String(params.viewId ?? "");
|
|
29
|
+
const request = normalizeMutate({ op: params.op, id: params.id, patch: params.patch });
|
|
30
|
+
if (!request) throw new Error("invalid mutate request — expected { op: 'update'|'delete', id, patch? }");
|
|
31
|
+
const collection = await deps.loadCollection(slug);
|
|
32
|
+
if (!collection) throw new Error(`collection '${slug}' not found`);
|
|
33
|
+
const result = await deps.mutateRemoteView(collection, viewId, request);
|
|
34
|
+
if (result.kind !== "ok") throw new Error(mutateRemoteViewFailureMessage(result, slug));
|
|
35
|
+
// Plain JSON, but the interface lacks an index signature — cast like the
|
|
36
|
+
// other phase-2/3 handlers.
|
|
37
|
+
return (result.op === "delete" ? { op: "delete", id: result.id } : { op: "update", item: result.item }) as unknown as JsonObject;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const mutateRemoteViewItem = createMutateRemoteViewHandler({ loadCollection, mutateRemoteView });
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Downscaled `data:` URL thumbnails for remote (mobile) custom views
|
|
2
|
+
// (plans/feat-remote-view-images.md). A phone can't reach the host's localhost,
|
|
3
|
+
// so an `image`-type field's workspace path is unrenderable there; a view that
|
|
4
|
+
// lists the field in `imageFields` gets it inlined as a small JPEG data URL the
|
|
5
|
+
// host produces here. Kept a leaf util (no collection/remote-view imports) so
|
|
6
|
+
// the builder in remoteView.ts is the only wiring point.
|
|
7
|
+
//
|
|
8
|
+
// Reads are workspace-containment-guarded (resolveWithinRoot, same discipline as
|
|
9
|
+
// image-store.ts). Results are cached by (path, mtime, maxEdge) so repeated
|
|
10
|
+
// pages / "load more" scrolls never re-decode the same source.
|
|
11
|
+
import { readFile, realpath, stat } from "fs/promises";
|
|
12
|
+
import { workspacePath } from "../../workspace/paths.js";
|
|
13
|
+
import { resolveWithinRoot } from "./safe.js";
|
|
14
|
+
import { log } from "../../system/logger/index.js";
|
|
15
|
+
|
|
16
|
+
/** Decode → downscale to fit `maxEdge` (never enlarge) → re-encode JPEG.
|
|
17
|
+
* Injected so tests exercise the resolver without the native `sharp` binary. */
|
|
18
|
+
export type ResizeToJpeg = (input: Buffer, maxEdge: number) => Promise<Buffer>;
|
|
19
|
+
|
|
20
|
+
// JPEG quality for inlined thumbnails — a size/fidelity knob; 72 keeps a
|
|
21
|
+
// 512px thumbnail in the low tens of KB, well within the page budget.
|
|
22
|
+
const THUMBNAIL_JPEG_QUALITY = 72;
|
|
23
|
+
|
|
24
|
+
const sharpResize: ResizeToJpeg = async (input, maxEdge) => {
|
|
25
|
+
// Dynamic import: only the default resolver pulls the native binary, so tests
|
|
26
|
+
// (which inject their own resize) and code paths that never make a thumbnail
|
|
27
|
+
// don't load it.
|
|
28
|
+
const sharp = (await import("sharp")).default;
|
|
29
|
+
// `.rotate()` bakes in EXIF orientation so portrait phone photos aren't sideways.
|
|
30
|
+
return sharp(input).rotate().resize(maxEdge, maxEdge, { fit: "inside", withoutEnlargement: true }).jpeg({ quality: THUMBNAIL_JPEG_QUALITY }).toBuffer();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
interface CacheEntry {
|
|
34
|
+
mtimeMs: number;
|
|
35
|
+
maxEdge: number;
|
|
36
|
+
dataUrl: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// In-memory, per host process. Bounded LRU (Map keeps insertion order; a get
|
|
40
|
+
// re-inserts to mark recency, a set past the cap evicts the oldest).
|
|
41
|
+
const MAX_CACHE_ENTRIES = 256;
|
|
42
|
+
const cache = new Map<string, CacheEntry>();
|
|
43
|
+
|
|
44
|
+
function cacheGet(relPath: string, mtimeMs: number, maxEdge: number): string | null {
|
|
45
|
+
const entry = cache.get(relPath);
|
|
46
|
+
if (!entry || entry.mtimeMs !== mtimeMs || entry.maxEdge !== maxEdge) return null;
|
|
47
|
+
cache.delete(relPath);
|
|
48
|
+
cache.set(relPath, entry);
|
|
49
|
+
return entry.dataUrl;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cacheSet(relPath: string, entry: CacheEntry): void {
|
|
53
|
+
cache.set(relPath, entry);
|
|
54
|
+
if (cache.size > MAX_CACHE_ENTRIES) {
|
|
55
|
+
const oldest = cache.keys().next().value;
|
|
56
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Test-only: drop the cache so a spy on the resize fn sees a fresh encode. */
|
|
61
|
+
export function clearThumbnailCache(): void {
|
|
62
|
+
cache.clear();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve a workspace-relative image path to a downscaled JPEG `data:` URL, or
|
|
66
|
+
* `null` when the path escapes the workspace, is missing, or can't be decoded —
|
|
67
|
+
* the caller then leaves the field as its original path (rendered as a
|
|
68
|
+
* placeholder by the view). `maxEdge` should already be clamped by the caller
|
|
69
|
+
* (`clampImageMaxEdge`). */
|
|
70
|
+
export function createThumbnailResolver(resize: ResizeToJpeg = sharpResize) {
|
|
71
|
+
return async function resolveThumbnail(relPath: string, maxEdge: number): Promise<string | null> {
|
|
72
|
+
if (typeof relPath !== "string" || relPath.length === 0) return null;
|
|
73
|
+
let root: string;
|
|
74
|
+
try {
|
|
75
|
+
root = await realpath(workspacePath);
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const abs = resolveWithinRoot(root, relPath);
|
|
80
|
+
if (!abs) return null;
|
|
81
|
+
const info = await stat(abs).catch(() => null);
|
|
82
|
+
if (!info?.isFile()) return null;
|
|
83
|
+
const cached = cacheGet(relPath, info.mtimeMs, maxEdge);
|
|
84
|
+
if (cached) return cached;
|
|
85
|
+
try {
|
|
86
|
+
const out = await resize(await readFile(abs), maxEdge);
|
|
87
|
+
const dataUrl = `data:image/jpeg;base64,${out.toString("base64")}`;
|
|
88
|
+
cacheSet(relPath, { mtimeMs: info.mtimeMs, maxEdge, dataUrl });
|
|
89
|
+
return dataUrl;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
log.warn("thumbnail", "resolve failed", { relPath, error: String(err) });
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const resolveThumbnail = createThumbnailResolver();
|
|
@@ -99,6 +99,12 @@ export async function backfillAllSessions(
|
|
|
99
99
|
opts: {
|
|
100
100
|
workspaceRoot?: string;
|
|
101
101
|
deps?: IndexerDeps;
|
|
102
|
+
// Opt-in to "regenerate every summary, even those still current".
|
|
103
|
+
// Default false — the scheduled tick uses that so unchanged
|
|
104
|
+
// sessions cost only a stat + entry read, not a Claude CLI call.
|
|
105
|
+
// The manual rebuild endpoint and CHAT_INDEX_FORCE_RUN_ON_STARTUP
|
|
106
|
+
// opt in explicitly, matching their debug / rollout intent (#1929).
|
|
107
|
+
force?: boolean;
|
|
102
108
|
} = {},
|
|
103
109
|
): Promise<BackfillResult> {
|
|
104
110
|
const workspaceRoot = opts.workspaceRoot ?? defaultWorkspacePath;
|
|
@@ -108,6 +114,7 @@ export async function backfillAllSessions(
|
|
|
108
114
|
indexed: 0,
|
|
109
115
|
skipped: 0,
|
|
110
116
|
};
|
|
117
|
+
const force = opts.force === true;
|
|
111
118
|
for (const sessionId of ids) {
|
|
112
119
|
if (disabled) {
|
|
113
120
|
result.skipped++;
|
|
@@ -116,7 +123,7 @@ export async function backfillAllSessions(
|
|
|
116
123
|
try {
|
|
117
124
|
const entry = await indexSession(workspaceRoot, sessionId, {
|
|
118
125
|
...(opts.deps ?? {}),
|
|
119
|
-
force: true,
|
|
126
|
+
...(force ? { force: true } : {}),
|
|
120
127
|
});
|
|
121
128
|
if (entry) {
|
|
122
129
|
result.indexed++;
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// at a `mkdtempSync` directory without touching the real
|
|
9
9
|
// ~/mulmoclaude.
|
|
10
10
|
|
|
11
|
-
import { readdir, readFile, rm } from "node:fs/promises";
|
|
11
|
+
import { readdir, readFile, rm, stat } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
12
13
|
import { defaultSummarize, loadJsonlInput, type SummarizeFn } from "./summarizer.js";
|
|
13
14
|
import { chatDirFor, indexEntryPathFor, manifestPathFor, sessionJsonlPathFor, sessionMetaPathFor } from "./paths.js";
|
|
14
15
|
import type { ChatIndexEntry, ChatIndexManifest } from "./types.js";
|
|
@@ -22,6 +23,12 @@ import { isRecord } from "../../utils/types.js";
|
|
|
22
23
|
// — long enough that a single conversation doesn't re-summarize
|
|
23
24
|
// every turn, short enough that a user who leaves for lunch and
|
|
24
25
|
// comes back sees the title refresh.
|
|
26
|
+
//
|
|
27
|
+
// Complementary to the content-change gate below: `isFresh` says
|
|
28
|
+
// "we JUST indexed, don't retry mid-conversation", while
|
|
29
|
+
// `sessionJsonlChangedSinceIndex` says "the jsonl hasn't been
|
|
30
|
+
// touched since we last indexed, no work to do". Both run when
|
|
31
|
+
// `force` is false; `force: true` bypasses both.
|
|
25
32
|
export const MIN_INDEX_INTERVAL_MS = 15 * ONE_MINUTE_MS;
|
|
26
33
|
|
|
27
34
|
// Injection points for tests. Defaults are the production spawn +
|
|
@@ -30,10 +37,13 @@ export interface IndexerDeps {
|
|
|
30
37
|
summarize?: SummarizeFn;
|
|
31
38
|
now?: () => number;
|
|
32
39
|
minIntervalMs?: number;
|
|
33
|
-
// Bypass the `isFresh` freshness throttle
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
40
|
+
// Bypass both the `isFresh` freshness throttle AND the content-
|
|
41
|
+
// changed gate (`sessionJsonlChangedSinceIndex`). Used by the
|
|
42
|
+
// manual rebuild endpoint and the `CHAT_INDEX_FORCE_RUN_ON_STARTUP`
|
|
43
|
+
// startup path so "regenerate everything" semantics keep working
|
|
44
|
+
// even when the summariser is unchanged — e.g. the summariser
|
|
45
|
+
// prompt was edited and existing summaries are stale by design,
|
|
46
|
+
// not by content.
|
|
37
47
|
force?: boolean;
|
|
38
48
|
}
|
|
39
49
|
|
|
@@ -110,34 +120,106 @@ export async function updateManifest(workspaceRoot: string, mutator: (m: ChatInd
|
|
|
110
120
|
// disk. Both removals tolerate "missing" — sessions that were never
|
|
111
121
|
// indexed have no entry to prune.
|
|
112
122
|
export async function removeSessionFromIndex(workspaceRoot: string, sessionId: string): Promise<void> {
|
|
113
|
-
|
|
123
|
+
const safeId = safeSessionIdOrNull(sessionId);
|
|
124
|
+
if (safeId === null) return;
|
|
125
|
+
await rm(indexEntryPathFor(workspaceRoot, safeId), { force: true });
|
|
114
126
|
await updateManifest(workspaceRoot, (manifest) => ({
|
|
115
127
|
...manifest,
|
|
116
|
-
entries: manifest.entries.filter((entry) => entry.id !==
|
|
128
|
+
entries: manifest.entries.filter((entry) => entry.id !== safeId),
|
|
117
129
|
}));
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
// --- freshness check ------------------------------------------------
|
|
121
133
|
|
|
134
|
+
// Shared parsing of `<indexDir>/<sessionId>.json` → `indexedAt` as
|
|
135
|
+
// milliseconds since epoch. Returns null on any failure (file
|
|
136
|
+
// missing, unreadable, JSON parse error, wrong shape, unparseable
|
|
137
|
+
// timestamp) so callers can each pick their own "no entry" semantic
|
|
138
|
+
// (skip vs reindex vs throttle) without duplicating the read.
|
|
139
|
+
// Exported so `isFresh` / `sessionJsonlChangedSinceIndex` share one
|
|
140
|
+
// canonical parse — CodeRabbit review on #1930.
|
|
141
|
+
export async function readIndexedAtMs(workspaceRoot: string, sessionId: string): Promise<number | null> {
|
|
142
|
+
const safeId = safeSessionIdOrNull(sessionId);
|
|
143
|
+
if (safeId === null) return null;
|
|
144
|
+
try {
|
|
145
|
+
const raw = await readFile(indexEntryPathFor(workspaceRoot, safeId), "utf-8");
|
|
146
|
+
const entry: unknown = JSON.parse(raw);
|
|
147
|
+
if (!isRecord(entry)) return null;
|
|
148
|
+
const { indexedAt } = entry as Record<string, unknown>;
|
|
149
|
+
if (typeof indexedAt !== "string") return null;
|
|
150
|
+
const parsed = Date.parse(indexedAt);
|
|
151
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
122
157
|
// A session is "fresh" when its per-session index file exists and
|
|
123
158
|
// was written less than `minIntervalMs` ago. Fresh sessions are
|
|
124
159
|
// skipped so a long conversation doesn't spam the CLI on every
|
|
125
|
-
// turn.
|
|
160
|
+
// turn. Delegates disk / parse work to `readIndexedAtMs` so the
|
|
161
|
+
// two throttles stay in lockstep on entry-file semantics.
|
|
126
162
|
export async function isFresh(workspaceRoot: string, sessionId: string, now: number, minIntervalMs: number): Promise<boolean> {
|
|
163
|
+
const indexedMs = await readIndexedAtMs(workspaceRoot, sessionId);
|
|
164
|
+
if (indexedMs === null) return false;
|
|
165
|
+
return now - indexedMs < minIntervalMs;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Returns true when the session's jsonl was written / appended AFTER
|
|
169
|
+
// its last index entry — i.e. there IS new content to summarize.
|
|
170
|
+
//
|
|
171
|
+
// Complements `isFresh`: without this gate, the hourly scheduler
|
|
172
|
+
// tick re-summarizes every session even when nothing new was
|
|
173
|
+
// written since the last index (issue #1929). With it, the tick's
|
|
174
|
+
// per-session cost drops to O(stat + file read) for unchanged
|
|
175
|
+
// sessions and only spends a Claude CLI call on the ones that
|
|
176
|
+
// actually saw a new turn.
|
|
177
|
+
//
|
|
178
|
+
// Return-value contract, for each unknown state:
|
|
179
|
+
// - Hostile / malformed sessionId → false (skip; something upstream
|
|
180
|
+
// handed us a value that could escape the chat dir via `..`).
|
|
181
|
+
// - Entry file missing/malformed → true (we've never captured this
|
|
182
|
+
// session; index it).
|
|
183
|
+
// - Jsonl file missing → false (nothing to reindex; the caller
|
|
184
|
+
// would return null downstream anyway, but we short-circuit).
|
|
185
|
+
// - Otherwise → jsonl mtime > entry.indexedAt.
|
|
186
|
+
export async function sessionJsonlChangedSinceIndex(workspaceRoot: string, sessionId: string): Promise<boolean> {
|
|
187
|
+
const safeId = safeSessionIdOrNull(sessionId);
|
|
188
|
+
if (safeId === null) return false;
|
|
189
|
+
const indexedMs = await readIndexedAtMs(workspaceRoot, safeId);
|
|
190
|
+
if (indexedMs === null) return true;
|
|
127
191
|
try {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
if (!isRecord(entry)) return false;
|
|
131
|
-
const { indexedAt } = entry as Record<string, unknown>;
|
|
132
|
-
if (typeof indexedAt !== "string") return false;
|
|
133
|
-
const indexedTimestamp = Date.parse(indexedAt);
|
|
134
|
-
if (Number.isNaN(indexedTimestamp)) return false;
|
|
135
|
-
return now - indexedTimestamp < minIntervalMs;
|
|
192
|
+
const info = await stat(sessionJsonlPathFor(workspaceRoot, safeId));
|
|
193
|
+
return info.mtimeMs > indexedMs;
|
|
136
194
|
} catch {
|
|
137
195
|
return false;
|
|
138
196
|
}
|
|
139
197
|
}
|
|
140
198
|
|
|
199
|
+
// Path-traversal guard for session IDs used to derive on-disk paths.
|
|
200
|
+
// Two-step check so both a static analyser (CodeQL taints
|
|
201
|
+
// `sessionId` as a possible route-param input) AND a human reader
|
|
202
|
+
// can convince themselves the derived path stays inside the chat
|
|
203
|
+
// dir:
|
|
204
|
+
//
|
|
205
|
+
// 1. `path.basename()` collapses any `..` / separator to the last
|
|
206
|
+
// segment. CodeQL recognises this as a barrier — a value whose
|
|
207
|
+
// taint originates upstream is neutralised here.
|
|
208
|
+
// 2. `SAFE_SESSION_ID_RE` narrows the character class further
|
|
209
|
+
// (word chars, `.`, `-`, up to 200 long) and rejects any
|
|
210
|
+
// `..` substring, mirroring `server/api/bridge/sessionRole.ts`.
|
|
211
|
+
//
|
|
212
|
+
// Returns the cleaned id on success (identical to the input for
|
|
213
|
+
// legit ids) or `null` on any hostile / malformed input.
|
|
214
|
+
const SAFE_SESSION_ID_RE = /^[\w.-]{1,200}$/;
|
|
215
|
+
export function safeSessionIdOrNull(sessionId: string): string | null {
|
|
216
|
+
const basename = path.basename(sessionId);
|
|
217
|
+
if (basename !== sessionId) return null;
|
|
218
|
+
if (!SAFE_SESSION_ID_RE.test(basename)) return null;
|
|
219
|
+
if (basename.includes("..")) return null;
|
|
220
|
+
return basename;
|
|
221
|
+
}
|
|
222
|
+
|
|
141
223
|
// --- session metadata ----------------------------------------------
|
|
142
224
|
|
|
143
225
|
interface SessionMeta {
|
|
@@ -146,8 +228,14 @@ interface SessionMeta {
|
|
|
146
228
|
}
|
|
147
229
|
|
|
148
230
|
async function readSessionMeta(workspaceRoot: string, sessionId: string): Promise<SessionMeta> {
|
|
231
|
+
// Defence-in-depth: only `indexSession` calls this (with an
|
|
232
|
+
// already-sanitized id), but a future caller wiring the helper
|
|
233
|
+
// into a new code path shouldn't be able to escape the chat dir
|
|
234
|
+
// by mistake.
|
|
235
|
+
const safeId = safeSessionIdOrNull(sessionId);
|
|
236
|
+
if (safeId === null) return {};
|
|
149
237
|
try {
|
|
150
|
-
const raw = await readFile(sessionMetaPathFor(workspaceRoot,
|
|
238
|
+
const raw = await readFile(sessionMetaPathFor(workspaceRoot, safeId), "utf-8");
|
|
151
239
|
const parsed: unknown = JSON.parse(raw);
|
|
152
240
|
if (!isRecord(parsed)) return {};
|
|
153
241
|
const metaRecord = parsed as Record<string, unknown>;
|
|
@@ -161,11 +249,22 @@ async function readSessionMeta(workspaceRoot: string, sessionId: string): Promis
|
|
|
161
249
|
}
|
|
162
250
|
|
|
163
251
|
// List every session id that has a .jsonl file in the workspace
|
|
164
|
-
// chat dir. Used by the backfill helper.
|
|
252
|
+
// chat dir. Used by the backfill helper. Filenames from disk are
|
|
253
|
+
// filtered through `safeSessionIdOrNull` so a stray file with a
|
|
254
|
+
// hostile name (`.` / `..` / a slash-containing name that survived
|
|
255
|
+
// readdir on some FS) can never flow through the backfill loop
|
|
256
|
+
// into `indexSession`.
|
|
165
257
|
export async function listSessionIds(workspaceRoot: string): Promise<string[]> {
|
|
166
258
|
try {
|
|
167
259
|
const files = await readdir(chatDirFor(workspaceRoot));
|
|
168
|
-
|
|
260
|
+
const ids: string[] = [];
|
|
261
|
+
for (const fileName of files) {
|
|
262
|
+
if (!fileName.endsWith(".jsonl")) continue;
|
|
263
|
+
const stem = fileName.slice(0, -".jsonl".length);
|
|
264
|
+
const safeId = safeSessionIdOrNull(stem);
|
|
265
|
+
if (safeId !== null) ids.push(safeId);
|
|
266
|
+
}
|
|
267
|
+
return ids;
|
|
169
268
|
} catch {
|
|
170
269
|
return [];
|
|
171
270
|
}
|
|
@@ -175,27 +274,43 @@ export async function listSessionIds(workspaceRoot: string): Promise<string[]> {
|
|
|
175
274
|
|
|
176
275
|
// Index (or re-index) a single session. Returns the entry on
|
|
177
276
|
// success, or null if the session was skipped (fresh, empty,
|
|
178
|
-
// missing). The only exception that escapes is
|
|
277
|
+
// missing, or hostile id). The only exception that escapes is
|
|
179
278
|
// `ClaudeCliNotFoundError` — the caller uses it to disable the
|
|
180
279
|
// module for the rest of the process lifetime.
|
|
280
|
+
//
|
|
281
|
+
// `sessionId` is sanitized at entry (`safeSessionIdOrNull`) and the
|
|
282
|
+
// resulting `safeId` is threaded through every downstream file
|
|
283
|
+
// access, so `force: true` — a debug / rollout knob — cannot become
|
|
284
|
+
// a path-injection escape hatch (Codex review on #1930).
|
|
181
285
|
export async function indexSession(workspaceRoot: string, sessionId: string, deps: IndexerDeps = {}): Promise<ChatIndexEntry | null> {
|
|
286
|
+
const safeId = safeSessionIdOrNull(sessionId);
|
|
287
|
+
if (safeId === null) return null;
|
|
182
288
|
const summarize = deps.summarize ?? defaultSummarize;
|
|
183
289
|
const now = (deps.now ?? Date.now)();
|
|
184
290
|
const minInterval = deps.minIntervalMs ?? MIN_INDEX_INTERVAL_MS;
|
|
185
291
|
const force = deps.force === true;
|
|
186
292
|
|
|
187
|
-
if (!force
|
|
188
|
-
|
|
293
|
+
if (!force) {
|
|
294
|
+
if (await isFresh(workspaceRoot, safeId, now, minInterval)) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
// Second gate: even past the freshness window, if the jsonl has
|
|
298
|
+
// NOT been written since the last index there's no new content
|
|
299
|
+
// to summarize. Cuts idle scheduler tick cost from O(sessions)
|
|
300
|
+
// Claude CLI calls to O(actual updates) — see #1929.
|
|
301
|
+
if (!(await sessionJsonlChangedSinceIndex(workspaceRoot, safeId))) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
189
304
|
}
|
|
190
305
|
|
|
191
|
-
const input = await loadJsonlInput(sessionJsonlPathFor(workspaceRoot,
|
|
306
|
+
const input = await loadJsonlInput(sessionJsonlPathFor(workspaceRoot, safeId));
|
|
192
307
|
if (!input.trim()) return null;
|
|
193
308
|
|
|
194
309
|
const summary = await summarize(input);
|
|
195
|
-
const meta = await readSessionMeta(workspaceRoot,
|
|
310
|
+
const meta = await readSessionMeta(workspaceRoot, safeId);
|
|
196
311
|
|
|
197
312
|
const entry: ChatIndexEntry = {
|
|
198
|
-
id:
|
|
313
|
+
id: safeId,
|
|
199
314
|
roleId: meta.roleId ?? DEFAULT_ROLE_ID,
|
|
200
315
|
startedAt: meta.startedAt ?? new Date(now).toISOString(),
|
|
201
316
|
indexedAt: new Date(now).toISOString(),
|
|
@@ -207,12 +322,12 @@ export async function indexSession(workspaceRoot: string, sessionId: string, dep
|
|
|
207
322
|
// Per-session file is written first so partial progress survives
|
|
208
323
|
// a crash between the two writes: the next run can still observe
|
|
209
324
|
// the fresh entry via isFresh and skip it.
|
|
210
|
-
await writeJsonAtomic(indexEntryPathFor(workspaceRoot,
|
|
325
|
+
await writeJsonAtomic(indexEntryPathFor(workspaceRoot, safeId), entry);
|
|
211
326
|
|
|
212
327
|
// Upsert into manifest under the in-process lock: replace any
|
|
213
328
|
// prior entry with the same id, sort newest-first by startedAt.
|
|
214
329
|
await updateManifest(workspaceRoot, (current) => {
|
|
215
|
-
const filtered = current.entries.filter((entryItem) => entryItem.id !==
|
|
330
|
+
const filtered = current.entries.filter((entryItem) => entryItem.id !== safeId);
|
|
216
331
|
filtered.push(entry);
|
|
217
332
|
filtered.sort((leftEntry, rightEntry) => Date.parse(rightEntry.startedAt) - Date.parse(leftEntry.startedAt));
|
|
218
333
|
return { version: 1, entries: filtered };
|