pi-sessions 0.15.0 → 0.16.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/README.md +6 -8
- package/extensions/pi-sessions.ts +1 -3
- package/extensions/session-handoff/metadata.ts +2 -0
- package/extensions/session-handoff/query.ts +5 -4
- package/extensions/session-hooks/install.ts +14 -5
- package/extensions/session-index/install.ts +25 -4
- package/extensions/session-index/panel.ts +9 -0
- package/extensions/shared/session-index/access.ts +42 -5
- package/extensions/shared/session-index/common.ts +1 -0
- package/extensions/shared/session-index/recovery.ts +270 -0
- package/extensions/shared/session-index/schema.ts +9 -1
- package/extensions/shared/session-index/sqlite.ts +20 -10
- package/extensions/{session-search/hooks.ts → shared/session-index/sync.ts} +40 -16
- package/package.json +1 -1
- package/extensions/session-search/reindex.ts +0 -97
package/README.md
CHANGED
|
@@ -51,9 +51,8 @@ pi -e /absolute/path/to/pi-sessions
|
|
|
51
51
|
## Quick start
|
|
52
52
|
|
|
53
53
|
1. Install the package.
|
|
54
|
-
2. Open Pi
|
|
55
|
-
3.
|
|
56
|
-
4. Try the main flows:
|
|
54
|
+
2. Open Pi. Your prior sessions are indexed automatically in the background.
|
|
55
|
+
3. Try the main flows:
|
|
57
56
|
|
|
58
57
|
```text
|
|
59
58
|
What session did I implement the db layer?
|
|
@@ -75,7 +74,7 @@ Open the frontend implementation task in a session to the right.
|
|
|
75
74
|
| Session Index | `/session-index` slash command | Shows index status and rebuilds the local session index |
|
|
76
75
|
| Session Auto Title | in background, `/title` slash command | Give sessions titles |
|
|
77
76
|
|
|
78
|
-
Every feature is on by default. Turn one off with `enable: false` under its own settings namespace,
|
|
77
|
+
Every feature is on by default. Turn one off with `enable: false` under its own settings namespace. Index recovery and startup/switch sync always run, including when search is disabled. `hooks.enable` controls turn, tree, and compaction sync, not startup recovery.
|
|
79
78
|
|
|
80
79
|
```json
|
|
81
80
|
{
|
|
@@ -230,12 +229,11 @@ Subagents require the handoff and messaging features. Limit recursive delegation
|
|
|
230
229
|
|
|
231
230
|
## Session Index
|
|
232
231
|
|
|
233
|
-
|
|
232
|
+
The index is a shared cache of your transcripts, used by search, messaging, and the other session features. Pi checks transcripts at startup and syncs files that changed, including sessions run without pi-sessions loaded. Missing, older-schema, or unreadable indexes rebuild automatically in the background; unreadable databases are moved aside rather than overwritten. A malformed transcript is skipped and reported.
|
|
234
233
|
|
|
235
|
-
|
|
236
|
-
- hit `r` to (re)index everything
|
|
234
|
+
During recovery, a tool may say "Session indexing in progress; try again shortly." Only one process rebuilds at a time, and readers retain the old index until its replacement is ready. Failed attempts share a backoff of 1 minute, then 5 minutes, then 30 minutes. `/session-index` shows status; press `r` to rebuild now, ignoring the backoff.
|
|
237
235
|
|
|
238
|
-
|
|
236
|
+
If another Pi writes a newer index schema, the older process stops using the index and displays "pi-sessions was updated; /reload to use it". Reload instead of rebuilding: older code cannot replace a newer schema.
|
|
239
237
|
|
|
240
238
|
By default the index lives at:
|
|
241
239
|
|
|
@@ -156,9 +156,7 @@ export default function piSessions(pi: ExtensionAPI): void {
|
|
|
156
156
|
}),
|
|
157
157
|
);
|
|
158
158
|
}
|
|
159
|
-
|
|
160
|
-
lifecycles.push(installHooks(pi, { index }));
|
|
161
|
-
}
|
|
159
|
+
lifecycles.push(index, installHooks(pi, { index, turnSync: settings.features.hooks }));
|
|
162
160
|
// Messaging leads every session_start so the broker connection is registered before any
|
|
163
161
|
// other feature hook runs. A side effect is that messaging's first relation snapshot can
|
|
164
162
|
// predate hooks' index sync for this session; getCachedRelationTo self-heals on miss, so
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from "./launch-target.ts";
|
|
11
11
|
|
|
12
12
|
export const HANDOFF_METADATA_CUSTOM_TYPE = "pi-sessions.handoff";
|
|
13
|
+
// Agent Switchboard's swb adopt (packages/cli/src/commands/adopt.ts) reads this
|
|
14
|
+
// customType and data.subagent; renaming either breaks its subagent check.
|
|
13
15
|
export const HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap";
|
|
14
16
|
export const HANDOFF_BOOTSTRAP_CONSUMED_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap-consumed";
|
|
15
17
|
export const HANDOFF_BOOTSTRAP_FAILED_CUSTOM_TYPE = "pi-sessions.handoff-bootstrap-failed";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { stripSearchSnippetMarkers } from "../shared/search-snippet.ts";
|
|
2
2
|
import {
|
|
3
|
+
formatRequiredSessionIndexError,
|
|
3
4
|
getSessionByPath,
|
|
4
5
|
type SearchSessionResult,
|
|
5
6
|
type SessionIndexDatabase,
|
|
@@ -100,7 +101,7 @@ export function listSessionPickerItems(
|
|
|
100
101
|
defaultScopeLabel,
|
|
101
102
|
};
|
|
102
103
|
}) ?? {
|
|
103
|
-
items: [buildIndexErrorItem()],
|
|
104
|
+
items: [buildIndexErrorItem(options.indexPath)],
|
|
104
105
|
scopeMode,
|
|
105
106
|
defaultScopeLabel,
|
|
106
107
|
}
|
|
@@ -309,11 +310,11 @@ function getSessionMarker(
|
|
|
309
310
|
}
|
|
310
311
|
}
|
|
311
312
|
|
|
312
|
-
function buildIndexErrorItem(): SessionPickerNoticeItem {
|
|
313
|
+
function buildIndexErrorItem(indexPath: string): SessionPickerNoticeItem {
|
|
313
314
|
return {
|
|
314
315
|
kind: "error",
|
|
315
|
-
title: "Session index
|
|
316
|
-
description:
|
|
316
|
+
title: "Session index unavailable",
|
|
317
|
+
description: formatRequiredSessionIndexError(indexPath),
|
|
317
318
|
};
|
|
318
319
|
}
|
|
319
320
|
|
|
@@ -3,28 +3,37 @@ import {
|
|
|
3
3
|
findPendingHandoffBootstrap,
|
|
4
4
|
getHandoffMetadataFromEntries,
|
|
5
5
|
} from "../session-handoff/metadata.ts";
|
|
6
|
-
import { createSessionHookController } from "../session-search/hooks.ts";
|
|
7
6
|
import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
|
|
7
|
+
import { requestIndexRecovery } from "../shared/session-index/recovery.ts";
|
|
8
|
+
import { createSessionHookController } from "../shared/session-index/sync.ts";
|
|
8
9
|
|
|
9
|
-
export function installHooks(
|
|
10
|
+
export function installHooks(
|
|
11
|
+
pi: ExtensionAPI,
|
|
12
|
+
deps: { index: IndexHandle; turnSync: boolean },
|
|
13
|
+
): SessionLifecycle {
|
|
10
14
|
const controller = createSessionHookController({ indexPath: deps.index.path });
|
|
11
15
|
|
|
12
16
|
pi.on("turn_end", async (_event, ctx) => {
|
|
13
|
-
await controller.handleTurnEnd(ctx.sessionManager.getSessionFile());
|
|
17
|
+
if (deps.turnSync) await controller.handleTurnEnd(ctx.sessionManager.getSessionFile());
|
|
14
18
|
});
|
|
15
19
|
|
|
16
20
|
pi.on("session_tree", async (_event, ctx) => {
|
|
17
|
-
await controller.handleSessionTree(ctx.sessionManager.getSessionFile());
|
|
21
|
+
if (deps.turnSync) await controller.handleSessionTree(ctx.sessionManager.getSessionFile());
|
|
18
22
|
});
|
|
19
23
|
|
|
20
24
|
pi.on("session_compact", async (_event, ctx) => {
|
|
21
|
-
await controller.handleSessionCompact(ctx.sessionManager.getSessionFile());
|
|
25
|
+
if (deps.turnSync) await controller.handleSessionCompact(ctx.sessionManager.getSessionFile());
|
|
22
26
|
});
|
|
23
27
|
|
|
24
28
|
return {
|
|
25
29
|
async onSessionStart(event, ctx) {
|
|
26
30
|
const { reason, previousSessionFile } = event;
|
|
27
31
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
32
|
+
void requestIndexRecovery(deps.index.path, true)
|
|
33
|
+
.then(() => controller.handleSessionStart(ctx.sessionManager.getSessionFile()))
|
|
34
|
+
.catch((error: unknown) =>
|
|
35
|
+
ctx.ui.notify(`Session index sync failed: ${String(error)}`, "error"),
|
|
36
|
+
);
|
|
28
37
|
|
|
29
38
|
switch (reason) {
|
|
30
39
|
case "new":
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
import type { IndexHandle } from "../shared/composition.ts";
|
|
2
|
+
import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
|
|
4
3
|
import { isTuiMode } from "../shared/pi-mode.ts";
|
|
5
4
|
import { getIndexStatus } from "../shared/session-index/index.ts";
|
|
5
|
+
import {
|
|
6
|
+
getIndexRecoveryMessage,
|
|
7
|
+
observeIndexRecovery,
|
|
8
|
+
type ReindexResult,
|
|
9
|
+
rebuildSessionIndex,
|
|
10
|
+
} from "../shared/session-index/recovery.ts";
|
|
6
11
|
import type { SessionSettings } from "../shared/settings.ts";
|
|
7
12
|
|
|
8
13
|
import { ReindexLoader } from "./loader.ts";
|
|
9
14
|
import { type SessionIndexAction, SessionIndexPanel } from "./panel.ts";
|
|
10
15
|
|
|
11
|
-
export function installIndex(
|
|
16
|
+
export function installIndex(
|
|
17
|
+
pi: ExtensionAPI,
|
|
18
|
+
deps: { settings: SessionSettings },
|
|
19
|
+
): IndexHandle & SessionLifecycle {
|
|
12
20
|
const indexPath = deps.settings.index.path;
|
|
21
|
+
let lastNotice: string | undefined;
|
|
13
22
|
|
|
14
23
|
pi.registerCommand("session-index", {
|
|
15
24
|
description: "Open the session index control panel",
|
|
@@ -20,6 +29,7 @@ export function installIndex(pi: ExtensionAPI, deps: { settings: SessionSettings
|
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
const status = getIndexStatus(indexPath);
|
|
32
|
+
status.recoveryMessage = getIndexRecoveryMessage(indexPath);
|
|
23
33
|
const action = await ctx.ui.custom<SessionIndexAction>(
|
|
24
34
|
(tui, theme, _keybindings, done) =>
|
|
25
35
|
new SessionIndexPanel(theme, status, done, () => tui.requestRender()),
|
|
@@ -59,7 +69,18 @@ export function installIndex(pi: ExtensionAPI, deps: { settings: SessionSettings
|
|
|
59
69
|
},
|
|
60
70
|
});
|
|
61
71
|
|
|
62
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
path: indexPath,
|
|
74
|
+
onSessionStart(_event, ctx) {
|
|
75
|
+
observeIndexRecovery(indexPath, (message) => {
|
|
76
|
+
ctx.ui.setStatus("session-index", message);
|
|
77
|
+
if (message && message !== lastNotice && !message.includes("in progress")) {
|
|
78
|
+
ctx.ui.notify(message, "error");
|
|
79
|
+
}
|
|
80
|
+
lastNotice = message;
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
};
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
async function runReindexWithLoader(
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type TuiMouseEventResult,
|
|
7
7
|
truncateToWidth,
|
|
8
8
|
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
9
10
|
} from "@earendil-works/pi-tui";
|
|
10
11
|
import { type LegendHit, LegendPointer, layoutLegend, legendHitAt } from "../shared/legend.ts";
|
|
11
12
|
import type { SessionIndexStatus } from "../shared/session-index/index.ts";
|
|
@@ -73,6 +74,14 @@ export class SessionIndexPanel implements Focusable {
|
|
|
73
74
|
lines.push(
|
|
74
75
|
this.renderRow(innerWidth, ` Last full reindex: ${this.status.lastFullReindexAt ?? "n/a"}`),
|
|
75
76
|
);
|
|
77
|
+
if (this.status.recoveryMessage) {
|
|
78
|
+
for (const line of wrapTextWithAnsi(
|
|
79
|
+
this.status.recoveryMessage,
|
|
80
|
+
Math.max(1, innerWidth - 2),
|
|
81
|
+
)) {
|
|
82
|
+
lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("warning", line)}`));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
76
85
|
lines.push(this.renderRow(innerWidth, ""));
|
|
77
86
|
this.renderAction(lines, innerWidth, "R", "rebuild from disk", () => this.done("reindex"));
|
|
78
87
|
this.renderAction(lines, innerWidth, "Enter / Esc", "close", () => this.done(undefined));
|
|
@@ -6,6 +6,13 @@ import {
|
|
|
6
6
|
type SessionIndexDatabase,
|
|
7
7
|
type SessionIndexStatus,
|
|
8
8
|
} from "./common.ts";
|
|
9
|
+
import {
|
|
10
|
+
acquireIndexLock,
|
|
11
|
+
disableOlderIndexClient,
|
|
12
|
+
getIndexRecoveryMessage,
|
|
13
|
+
isIndexClientDisabled,
|
|
14
|
+
requestIndexRecovery,
|
|
15
|
+
} from "./recovery.ts";
|
|
9
16
|
import { getMetadata, openIndexDatabase } from "./schema.ts";
|
|
10
17
|
|
|
11
18
|
export type SessionIndexOpenMode = "read" | "write";
|
|
@@ -67,28 +74,57 @@ function openValidatedSessionIndexInternal(
|
|
|
67
74
|
indexPath: string,
|
|
68
75
|
options: WithSessionIndexOptions,
|
|
69
76
|
): ValidSessionIndex | undefined {
|
|
77
|
+
if (isIndexClientDisabled(indexPath)) return handleUnavailableIndex(indexPath, options);
|
|
70
78
|
if (!existsSync(indexPath)) {
|
|
71
79
|
return handleUnavailableIndex(indexPath, options);
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
let db: SessionIndexDatabase | undefined;
|
|
83
|
+
let release: (() => void) | undefined;
|
|
75
84
|
try {
|
|
85
|
+
if (options.mode === "write") {
|
|
86
|
+
release = acquireIndexLock(indexPath);
|
|
87
|
+
if (!release) return handleUnavailableIndex(indexPath, options);
|
|
88
|
+
}
|
|
76
89
|
db = openIndexDatabase(indexPath, {
|
|
77
90
|
create: false,
|
|
78
|
-
mode:
|
|
91
|
+
mode: "read",
|
|
79
92
|
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
80
93
|
});
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
94
|
+
const version = Number(getMetadata(db, "schema_version"));
|
|
95
|
+
if (version !== INDEX_SCHEMA_VERSION) {
|
|
83
96
|
const invalidDb = db;
|
|
84
97
|
db = undefined;
|
|
85
98
|
invalidDb.close();
|
|
99
|
+
release?.();
|
|
100
|
+
release = undefined;
|
|
101
|
+
if (version > INDEX_SCHEMA_VERSION) disableOlderIndexClient(indexPath);
|
|
86
102
|
return handleUnavailableIndex(indexPath, options);
|
|
87
103
|
}
|
|
88
|
-
|
|
104
|
+
const status = getIndexStatusFromDb(indexPath, db);
|
|
105
|
+
if (options.mode === "write") {
|
|
106
|
+
db.close();
|
|
107
|
+
db = undefined;
|
|
108
|
+
db = openIndexDatabase(indexPath, {
|
|
109
|
+
create: false,
|
|
110
|
+
mode: "write",
|
|
111
|
+
timeoutMs: options.timeoutMs,
|
|
112
|
+
});
|
|
113
|
+
const close = db.close;
|
|
114
|
+
const unlock = release;
|
|
115
|
+
db.close = () => {
|
|
116
|
+
try {
|
|
117
|
+
close();
|
|
118
|
+
} finally {
|
|
119
|
+
unlock?.();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
release = undefined;
|
|
123
|
+
}
|
|
89
124
|
return { db, status };
|
|
90
125
|
} catch (error) {
|
|
91
126
|
db?.close();
|
|
127
|
+
release?.();
|
|
92
128
|
return handleUnavailableIndex(indexPath, options, error);
|
|
93
129
|
}
|
|
94
130
|
}
|
|
@@ -112,7 +148,7 @@ export function getIndexStatusFromDb(dbPath: string, db: SessionIndexDatabase):
|
|
|
112
148
|
}
|
|
113
149
|
|
|
114
150
|
export function formatRequiredSessionIndexError(indexPath: string): string {
|
|
115
|
-
return
|
|
151
|
+
return getIndexRecoveryMessage(indexPath) ?? "Session indexing in progress; try again shortly.";
|
|
116
152
|
}
|
|
117
153
|
|
|
118
154
|
function handleUnavailableIndex(
|
|
@@ -120,6 +156,7 @@ function handleUnavailableIndex(
|
|
|
120
156
|
options: WithSessionIndexOptions,
|
|
121
157
|
cause?: unknown,
|
|
122
158
|
): undefined {
|
|
159
|
+
if (!isIndexClientDisabled(indexPath)) void requestIndexRecovery(indexPath);
|
|
123
160
|
if (!options.required) {
|
|
124
161
|
return undefined;
|
|
125
162
|
}
|
|
@@ -148,6 +148,7 @@ export interface SessionIndexStatus {
|
|
|
148
148
|
schemaVersion?: number | undefined;
|
|
149
149
|
sessionCount?: number | undefined;
|
|
150
150
|
lastFullReindexAt?: string | undefined;
|
|
151
|
+
recoveryMessage?: string | undefined;
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
export type SessionIndexDatabase = SqliteDatabase;
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { setTimeout as delay, setImmediate as yieldToEventLoop } from "node:timers/promises";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { type Static, Type } from "typebox";
|
|
7
|
+
import { listSessionFiles } from "../../session-search/extract.ts";
|
|
8
|
+
import { parseTypeBoxValue } from "../typebox.ts";
|
|
9
|
+
import { INDEX_SCHEMA_VERSION, ROW_COUNT_SCHEMA, type SessionIndexDatabase } from "./common.ts";
|
|
10
|
+
import {
|
|
11
|
+
ensureIndexDir,
|
|
12
|
+
getMetadata,
|
|
13
|
+
initializeSchema,
|
|
14
|
+
openIndexDatabase,
|
|
15
|
+
setMetadata,
|
|
16
|
+
} from "./schema.ts";
|
|
17
|
+
import { syncSessionFileWithDb } from "./sync.ts";
|
|
18
|
+
|
|
19
|
+
export const INDEX_UPDATED_WARNING = "pi-sessions was updated; /reload to use it";
|
|
20
|
+
const BUILD = `index-v${INDEX_SCHEMA_VERSION}:${createHash("sha256")
|
|
21
|
+
.update(
|
|
22
|
+
["recovery.ts", "schema.ts", "sync.ts"]
|
|
23
|
+
.map((file) => readFileSync(new URL(file, import.meta.url)))
|
|
24
|
+
.join(""),
|
|
25
|
+
)
|
|
26
|
+
.digest("hex")
|
|
27
|
+
.slice(0, 16)}`;
|
|
28
|
+
const FAILURE_SCHEMA = Type.Object({
|
|
29
|
+
attempts: Type.Integer({ minimum: 1 }),
|
|
30
|
+
nextAllowedAt: Type.Number(),
|
|
31
|
+
error: Type.String(),
|
|
32
|
+
build: Type.String(),
|
|
33
|
+
});
|
|
34
|
+
export type IndexRecoveryFailure = Static<typeof FAILURE_SCHEMA>;
|
|
35
|
+
export interface ReindexResult {
|
|
36
|
+
sessionCount: number;
|
|
37
|
+
chunkCount: number;
|
|
38
|
+
indexPath: string;
|
|
39
|
+
skipped: string[];
|
|
40
|
+
}
|
|
41
|
+
const running = new Map<string, Promise<ReindexResult | undefined>>();
|
|
42
|
+
const notices = new Map<string, (message: string | undefined) => void>();
|
|
43
|
+
const shown = new Map<string, string>();
|
|
44
|
+
const disabled = new Set<string>();
|
|
45
|
+
|
|
46
|
+
export function observeIndexRecovery(
|
|
47
|
+
indexPath: string,
|
|
48
|
+
notify: (message: string | undefined) => void,
|
|
49
|
+
): void {
|
|
50
|
+
notices.set(indexPath, notify);
|
|
51
|
+
const message = getIndexRecoveryMessage(indexPath);
|
|
52
|
+
notify(message);
|
|
53
|
+
if (message) shown.set(indexPath, message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function report(indexPath: string, message: string): void {
|
|
57
|
+
if (shown.get(indexPath) === message) return;
|
|
58
|
+
shown.set(indexPath, message);
|
|
59
|
+
const notify = notices.get(indexPath);
|
|
60
|
+
if (notify) notify(message);
|
|
61
|
+
else console.error(message);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function disableOlderIndexClient(indexPath: string): void {
|
|
65
|
+
disabled.add(indexPath);
|
|
66
|
+
report(indexPath, INDEX_UPDATED_WARNING);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isIndexClientDisabled(indexPath: string): boolean {
|
|
70
|
+
return disabled.has(indexPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readIndexRecoveryFailure(indexPath: string): IndexRecoveryFailure | undefined {
|
|
74
|
+
const file = `${indexPath}.failure.json`;
|
|
75
|
+
let raw: string;
|
|
76
|
+
try {
|
|
77
|
+
raw = readFileSync(file, "utf8");
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return parseTypeBoxValue(FAILURE_SCHEMA, JSON.parse(raw), "Invalid index recovery record");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getIndexRecoveryMessage(indexPath: string): string | undefined {
|
|
86
|
+
if (disabled.has(indexPath)) return INDEX_UPDATED_WARNING;
|
|
87
|
+
const failure = readIndexRecoveryFailure(indexPath);
|
|
88
|
+
if (failure)
|
|
89
|
+
return `Session indexing failed: ${failure.error}. Retry after ${new Date(failure.nextAllowedAt).toISOString()}; /session-index can rebuild now.`;
|
|
90
|
+
if (running.has(indexPath)) return "Session indexing in progress; try again shortly.";
|
|
91
|
+
if (existsSync(`${indexPath}.lock.sqlite`)) {
|
|
92
|
+
const release = acquireIndexLock(indexPath);
|
|
93
|
+
if (!release) return "Session indexing in progress; try again shortly.";
|
|
94
|
+
release();
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// All index writers share this lock with recovery. Reads are short-lived and may
|
|
100
|
+
// finish on the old inode after an atomic replacement (no WAL sidecars).
|
|
101
|
+
// SQLite owns the lock lifetime, including process death; never unlink its file.
|
|
102
|
+
export function acquireIndexLock(indexPath: string): (() => void) | undefined {
|
|
103
|
+
ensureIndexDir(dirname(indexPath));
|
|
104
|
+
let lock: SessionIndexDatabase | undefined;
|
|
105
|
+
try {
|
|
106
|
+
lock = openIndexDatabase(`${indexPath}.lock.sqlite`, { create: true, timeoutMs: 0 });
|
|
107
|
+
lock.exec("BEGIN EXCLUSIVE");
|
|
108
|
+
const held = lock;
|
|
109
|
+
return () => held.close();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
lock?.close();
|
|
112
|
+
if (isSqliteBusy(error)) return undefined;
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isSqliteBusy(error: unknown): boolean {
|
|
118
|
+
return (
|
|
119
|
+
error instanceof Error &&
|
|
120
|
+
(("code" in error && error.code === "SQLITE_BUSY") ||
|
|
121
|
+
("errcode" in error && error.errcode === 5))
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function requestIndexRecovery(
|
|
126
|
+
indexPath: string,
|
|
127
|
+
reconcile = false,
|
|
128
|
+
): Promise<ReindexResult | undefined> {
|
|
129
|
+
const pending = running.get(indexPath);
|
|
130
|
+
if (pending) return pending;
|
|
131
|
+
const sessionsDir = join(getAgentDir(), "sessions");
|
|
132
|
+
const task = yieldToEventLoop()
|
|
133
|
+
.then(() => recover(indexPath, sessionsDir, false, reconcile))
|
|
134
|
+
.catch((error: unknown) => {
|
|
135
|
+
report(indexPath, `Session indexing failed: ${String(error)}`);
|
|
136
|
+
return undefined;
|
|
137
|
+
})
|
|
138
|
+
.finally(() => running.delete(indexPath));
|
|
139
|
+
running.set(indexPath, task);
|
|
140
|
+
return task;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function rebuildSessionIndex(options: { indexPath: string }): Promise<ReindexResult> {
|
|
144
|
+
await running.get(options.indexPath);
|
|
145
|
+
const result = await recover(options.indexPath, join(getAgentDir(), "sessions"), true, true);
|
|
146
|
+
if (!result)
|
|
147
|
+
throw new Error(
|
|
148
|
+
getIndexRecoveryMessage(options.indexPath) ?? "Session index is busy; try again shortly.",
|
|
149
|
+
);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function recover(
|
|
154
|
+
indexPath: string,
|
|
155
|
+
sessionsDir: string,
|
|
156
|
+
manual: boolean,
|
|
157
|
+
reconcile: boolean,
|
|
158
|
+
): Promise<ReindexResult | undefined> {
|
|
159
|
+
if (disabled.has(indexPath)) return undefined;
|
|
160
|
+
let release = acquireIndexLock(indexPath);
|
|
161
|
+
const deadline = Date.now() + 30_000;
|
|
162
|
+
while (!release && Date.now() < deadline) {
|
|
163
|
+
await delay(50);
|
|
164
|
+
release = acquireIndexLock(indexPath);
|
|
165
|
+
}
|
|
166
|
+
if (!release) return undefined;
|
|
167
|
+
let db: SessionIndexDatabase | undefined;
|
|
168
|
+
const temp = `${indexPath}.${randomUUID()}.tmp`;
|
|
169
|
+
try {
|
|
170
|
+
const failure = readIndexRecoveryFailure(indexPath);
|
|
171
|
+
let version: number | undefined;
|
|
172
|
+
let corrupt = false;
|
|
173
|
+
if (existsSync(indexPath)) {
|
|
174
|
+
try {
|
|
175
|
+
const probe = openIndexDatabase(indexPath, { create: false, mode: "read" });
|
|
176
|
+
try {
|
|
177
|
+
version = Number(getMetadata(probe, "schema_version"));
|
|
178
|
+
if (version > INDEX_SCHEMA_VERSION) {
|
|
179
|
+
disableOlderIndexClient(indexPath);
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
probe.prepare("SELECT COUNT(*) FROM sessions").get();
|
|
183
|
+
} finally {
|
|
184
|
+
probe.close();
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (isSqliteBusy(error)) throw error;
|
|
188
|
+
corrupt = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!manual && failure && Date.now() < failure.nextAllowedAt) {
|
|
192
|
+
report(indexPath, getIndexRecoveryMessage(indexPath) ?? failure.error);
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
const rebuild = manual || corrupt || version !== INDEX_SCHEMA_VERSION;
|
|
196
|
+
if (!rebuild && !reconcile) {
|
|
197
|
+
if (!failure) notices.get(indexPath)?.(undefined);
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
db = openIndexDatabase(rebuild ? temp : indexPath, { create: rebuild });
|
|
201
|
+
if (rebuild) initializeSchema(db);
|
|
202
|
+
const skipped: string[] = [];
|
|
203
|
+
for (const file of listSessionFiles(sessionsDir)) {
|
|
204
|
+
if (!syncSessionFileWithDb(db, file, "reconcile")) skipped.push(file);
|
|
205
|
+
await yieldToEventLoop();
|
|
206
|
+
}
|
|
207
|
+
const indexedDb = db;
|
|
208
|
+
const count = (table: string) =>
|
|
209
|
+
parseTypeBoxValue(
|
|
210
|
+
ROW_COUNT_SCHEMA,
|
|
211
|
+
indexedDb.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get(),
|
|
212
|
+
"Invalid index count",
|
|
213
|
+
).count;
|
|
214
|
+
const result = {
|
|
215
|
+
sessionCount: count("sessions"),
|
|
216
|
+
chunkCount: count("session_text_chunks"),
|
|
217
|
+
indexPath,
|
|
218
|
+
skipped,
|
|
219
|
+
};
|
|
220
|
+
db.transaction(() => {
|
|
221
|
+
setMetadata(indexedDb, rebuild ? "indexed_at" : "reconciled_at", new Date().toISOString());
|
|
222
|
+
if (rebuild) setMetadata(indexedDb, "build", BUILD);
|
|
223
|
+
});
|
|
224
|
+
db.close();
|
|
225
|
+
db = undefined;
|
|
226
|
+
if (rebuild) {
|
|
227
|
+
if (corrupt) {
|
|
228
|
+
const backup = `${indexPath}.bad-${Date.now()}-${randomUUID()}`;
|
|
229
|
+
for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
|
230
|
+
if (existsSync(`${indexPath}${suffix}`))
|
|
231
|
+
renameSync(`${indexPath}${suffix}`, `${backup}${suffix}`);
|
|
232
|
+
}
|
|
233
|
+
} else if (existsSync(indexPath)) {
|
|
234
|
+
// Upgrade legacy WAL indexes before swapping: SQLite checkpoints and
|
|
235
|
+
// refuses the mode change while another connection still needs its WAL.
|
|
236
|
+
const previous = openIndexDatabase(indexPath, { create: false });
|
|
237
|
+
previous.close();
|
|
238
|
+
}
|
|
239
|
+
renameSync(temp, indexPath);
|
|
240
|
+
}
|
|
241
|
+
rmSync(`${indexPath}.failure.json`, { force: true });
|
|
242
|
+
shown.delete(indexPath);
|
|
243
|
+
notices.get(indexPath)?.(undefined);
|
|
244
|
+
if (skipped.length)
|
|
245
|
+
report(
|
|
246
|
+
indexPath,
|
|
247
|
+
`Session index skipped ${skipped.length} unreadable transcript(s): ${skipped.join(", ")}`,
|
|
248
|
+
);
|
|
249
|
+
return result;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
const attempts = (readIndexRecoveryFailure(indexPath)?.attempts ?? 0) + 1;
|
|
252
|
+
const failure: IndexRecoveryFailure = {
|
|
253
|
+
attempts,
|
|
254
|
+
nextAllowedAt: Date.now() + (attempts === 1 ? 60_000 : attempts === 2 ? 300_000 : 1_800_000),
|
|
255
|
+
error: error instanceof Error ? error.message : String(error),
|
|
256
|
+
build: BUILD,
|
|
257
|
+
};
|
|
258
|
+
const record = `${indexPath}.failure.json`;
|
|
259
|
+
writeFileSync(`${record}.tmp`, JSON.stringify(failure));
|
|
260
|
+
renameSync(`${record}.tmp`, record);
|
|
261
|
+
report(indexPath, getIndexRecoveryMessage(indexPath) ?? failure.error);
|
|
262
|
+
if (manual) throw error;
|
|
263
|
+
return undefined;
|
|
264
|
+
} finally {
|
|
265
|
+
db?.close();
|
|
266
|
+
for (const suffix of ["", "-wal", "-shm", "-journal"])
|
|
267
|
+
rmSync(`${temp}${suffix}`, { force: true });
|
|
268
|
+
release();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -22,7 +22,12 @@ export function openIndexDatabase(
|
|
|
22
22
|
): SessionIndexDatabase {
|
|
23
23
|
const create = options?.create ?? true;
|
|
24
24
|
const readonly = options?.mode === "read";
|
|
25
|
-
return openSqlite(dbPath, {
|
|
25
|
+
return openSqlite(dbPath, {
|
|
26
|
+
create,
|
|
27
|
+
readonly,
|
|
28
|
+
timeoutMs: options?.timeoutMs,
|
|
29
|
+
journalMode: "DELETE",
|
|
30
|
+
});
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export function initializeSchema(db: SessionIndexDatabase): void {
|
|
@@ -180,6 +185,9 @@ export function getIndexStatus(dbPath: string): SessionIndexStatus {
|
|
|
180
185
|
try {
|
|
181
186
|
db = openIndexDatabase(dbPath, { create: false, mode: "read" });
|
|
182
187
|
const schemaVersionRaw = getMetadata(db, "schema_version");
|
|
188
|
+
if (Number(schemaVersionRaw) !== INDEX_SCHEMA_VERSION) {
|
|
189
|
+
return { dbPath, exists: true, schemaVersion: Number(schemaVersionRaw) };
|
|
190
|
+
}
|
|
183
191
|
const lastFullReindexAt = getMetadata(db, "indexed_at");
|
|
184
192
|
const sessionCountRow = parseTypeBoxValue(
|
|
185
193
|
ROW_COUNT_SCHEMA,
|
|
@@ -47,7 +47,12 @@ function loadDatabaseConstructor(): SqliteConstructor {
|
|
|
47
47
|
|
|
48
48
|
export function openSqlite(
|
|
49
49
|
dbPath: string,
|
|
50
|
-
options: {
|
|
50
|
+
options: {
|
|
51
|
+
create: boolean;
|
|
52
|
+
readonly?: boolean | undefined;
|
|
53
|
+
timeoutMs?: number | undefined;
|
|
54
|
+
journalMode?: "DELETE" | "WAL";
|
|
55
|
+
},
|
|
51
56
|
): SqliteDatabase {
|
|
52
57
|
const Database = loadDatabaseConstructor();
|
|
53
58
|
const readonly = options.readonly ?? false;
|
|
@@ -66,15 +71,20 @@ export function openSqlite(
|
|
|
66
71
|
})
|
|
67
72
|
: new Database(dbPath, { readOnly: readonly });
|
|
68
73
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
try {
|
|
75
|
+
db.exec(`PRAGMA busy_timeout = ${options.timeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS}`);
|
|
76
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
77
|
+
|
|
78
|
+
if (!readonly) {
|
|
79
|
+
// journal_mode is the connection's first write-capable lock acquisition (and
|
|
80
|
+
// may run WAL recovery), and immediate transactions rely on busy_timeout to
|
|
81
|
+
// queue behind concurrent writers instead of failing.
|
|
82
|
+
db.exec(`PRAGMA journal_mode = ${options.journalMode ?? "WAL"}`);
|
|
83
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
db.close();
|
|
87
|
+
throw error;
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
return withStatementCache(db);
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { existsSync, type Stats, statSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
createSessionNameChunk,
|
|
4
|
+
type ExtractedSessionRecord,
|
|
5
|
+
type ExtractedSessionTail,
|
|
6
|
+
extractSessionRecord,
|
|
7
|
+
extractSessionTail,
|
|
8
|
+
inferSessionOrigin,
|
|
9
|
+
type SessionFileTouch,
|
|
10
|
+
} from "../../session-search/extract.ts";
|
|
11
|
+
import { deriveSessionRepoRoots } from "../../session-search/normalize.ts";
|
|
2
12
|
import {
|
|
3
13
|
clearSessionChunksBySourceKind,
|
|
4
14
|
clearSessionIndexedData,
|
|
@@ -14,17 +24,7 @@ import {
|
|
|
14
24
|
setMetadata,
|
|
15
25
|
upsertSession,
|
|
16
26
|
withSessionIndex,
|
|
17
|
-
} from "
|
|
18
|
-
import {
|
|
19
|
-
createSessionNameChunk,
|
|
20
|
-
type ExtractedSessionRecord,
|
|
21
|
-
type ExtractedSessionTail,
|
|
22
|
-
extractSessionRecord,
|
|
23
|
-
extractSessionTail,
|
|
24
|
-
inferSessionOrigin,
|
|
25
|
-
type SessionFileTouch,
|
|
26
|
-
} from "./extract.ts";
|
|
27
|
-
import { deriveSessionRepoRoots } from "./normalize.ts";
|
|
27
|
+
} from "./index.ts";
|
|
28
28
|
|
|
29
29
|
export interface SessionHookController {
|
|
30
30
|
handleSessionStart(sessionFile: string | undefined): Promise<boolean>;
|
|
@@ -107,32 +107,56 @@ interface TailSyncBaseline extends SessionRow {
|
|
|
107
107
|
indexedFileAnchor: string;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
function syncSessionFileWithDb(
|
|
110
|
+
export function syncSessionFileWithDb(
|
|
111
111
|
db: SessionIndexDatabase,
|
|
112
112
|
sessionFile: string,
|
|
113
113
|
eventType: string,
|
|
114
114
|
sessionOrigin?: SessionOrigin,
|
|
115
115
|
): boolean {
|
|
116
116
|
const baseline = asTailSyncBaseline(getSessionRowByPath(db, sessionFile));
|
|
117
|
-
|
|
117
|
+
let stat: Stats;
|
|
118
|
+
try {
|
|
119
|
+
stat = statSync(sessionFile);
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
118
123
|
|
|
119
124
|
if (baseline && isIndexCurrent(baseline, stat, sessionOrigin)) {
|
|
120
|
-
db.transaction(() => writeHookSyncMetadata(db, eventType));
|
|
125
|
+
if (eventType !== "reconcile") db.transaction(() => writeHookSyncMetadata(db, eventType));
|
|
121
126
|
return true;
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
if (baseline && stat.size > baseline.indexedFileSize) {
|
|
125
|
-
|
|
130
|
+
let tail: ExtractedSessionTail | undefined;
|
|
131
|
+
try {
|
|
132
|
+
tail = extractSessionTail(sessionFile, baseline);
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
126
136
|
if (tail && applyTailSync(db, baseline, tail, eventType, sessionOrigin)) {
|
|
127
137
|
return true;
|
|
128
138
|
}
|
|
129
139
|
}
|
|
130
140
|
|
|
131
|
-
|
|
141
|
+
let extracted: ExtractedSessionRecord | undefined;
|
|
142
|
+
try {
|
|
143
|
+
extracted = extractSessionRecord(sessionFile);
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
132
147
|
if (!extracted) {
|
|
133
148
|
return false;
|
|
134
149
|
}
|
|
135
150
|
|
|
151
|
+
const existing = getSessionById(db, extracted.sessionId);
|
|
152
|
+
if (
|
|
153
|
+
existing &&
|
|
154
|
+
existing.sessionPath !== sessionFile &&
|
|
155
|
+
(existing.modifiedAt > extracted.modifiedAt ||
|
|
156
|
+
(existing.modifiedAt === extracted.modifiedAt && existing.sessionPath > sessionFile))
|
|
157
|
+
)
|
|
158
|
+
return true;
|
|
159
|
+
|
|
136
160
|
applyFullSync(db, extracted, eventType, sessionOrigin);
|
|
137
161
|
return true;
|
|
138
162
|
}
|
package/package.json
CHANGED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
4
|
-
clearSessionIndexedData,
|
|
5
|
-
ensureIndexDir,
|
|
6
|
-
initializeSchema,
|
|
7
|
-
insertSessionFileTouch,
|
|
8
|
-
insertTextChunk,
|
|
9
|
-
openIndexDatabase,
|
|
10
|
-
rebuildSessionLineageRelations,
|
|
11
|
-
type SessionIndexDatabase,
|
|
12
|
-
setMetadata,
|
|
13
|
-
upsertSession,
|
|
14
|
-
} from "../shared/session-index/index.ts";
|
|
15
|
-
import { type ExtractedSessionRecord, extractSessionRecord, listSessionFiles } from "./extract.ts";
|
|
16
|
-
|
|
17
|
-
export interface ReindexOptions {
|
|
18
|
-
indexPath: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface ReindexResult {
|
|
22
|
-
sessionCount: number;
|
|
23
|
-
chunkCount: number;
|
|
24
|
-
indexPath: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export async function rebuildSessionIndex(options: ReindexOptions): Promise<ReindexResult> {
|
|
28
|
-
const indexPath = options.indexPath;
|
|
29
|
-
ensureIndexDir(path.dirname(indexPath));
|
|
30
|
-
const sessionFiles = listSessionFiles(path.join(getAgentDir(), "sessions"));
|
|
31
|
-
|
|
32
|
-
const db = openIndexDatabase(indexPath, { create: true });
|
|
33
|
-
try {
|
|
34
|
-
db.transaction(() => {
|
|
35
|
-
dropIndexTables(db);
|
|
36
|
-
initializeSchema(db);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
const indexedSessions = new Map<string, { modifiedAt: string; chunks: number }>();
|
|
40
|
-
let chunkCount = 0;
|
|
41
|
-
for (const sessionFile of sessionFiles) {
|
|
42
|
-
const extracted = extractSessionRecord(sessionFile);
|
|
43
|
-
if (!extracted) {
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const previous = indexedSessions.get(extracted.sessionId);
|
|
48
|
-
if (previous && previous.modifiedAt > extracted.modifiedAt) continue;
|
|
49
|
-
db.transaction(() => indexSession(db, extracted));
|
|
50
|
-
chunkCount += extracted.chunks.length - (previous?.chunks ?? 0);
|
|
51
|
-
indexedSessions.set(extracted.sessionId, {
|
|
52
|
-
modifiedAt: extracted.modifiedAt,
|
|
53
|
-
chunks: extracted.chunks.length,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
db.transaction(() => {
|
|
58
|
-
rebuildSessionLineageRelations(db);
|
|
59
|
-
setMetadata(db, "indexed_at", new Date().toISOString());
|
|
60
|
-
setMetadata(db, "session_source", "session files");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
return { sessionCount: indexedSessions.size, chunkCount, indexPath };
|
|
64
|
-
} finally {
|
|
65
|
-
db.close();
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function dropIndexTables(db: SessionIndexDatabase): void {
|
|
70
|
-
// Children before parents: foreign_keys is ON and DROP TABLE runs an
|
|
71
|
-
// implicit DELETE that parent-side constraints would reject.
|
|
72
|
-
db.exec(`
|
|
73
|
-
DROP TABLE IF EXISTS session_lineage_relations;
|
|
74
|
-
DROP TABLE IF EXISTS session_repo_roots;
|
|
75
|
-
DROP TABLE IF EXISTS session_text_chunks;
|
|
76
|
-
DROP TABLE IF EXISTS session_file_touches;
|
|
77
|
-
DROP TABLE IF EXISTS session_text_chunks_fts;
|
|
78
|
-
DROP TABLE IF EXISTS sessions;
|
|
79
|
-
DROP TABLE IF EXISTS metadata;
|
|
80
|
-
`);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function indexSession(db: SessionIndexDatabase, extracted: ExtractedSessionRecord): void {
|
|
84
|
-
clearSessionIndexedData(db, extracted.sessionId);
|
|
85
|
-
upsertSession(db, extracted, "full_reindex");
|
|
86
|
-
|
|
87
|
-
for (const chunk of extracted.chunks) {
|
|
88
|
-
insertTextChunk(db, { sessionId: extracted.sessionId, ...chunk });
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
for (const fileTouch of extracted.fileTouches) {
|
|
92
|
-
insertSessionFileTouch(db, {
|
|
93
|
-
sessionId: extracted.sessionId,
|
|
94
|
-
...fileTouch,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
}
|