pi-sessions 0.15.0 → 0.17.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 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 and run `/session-index`.
55
- 3. Press `r` to build the index for all your prior sessions.
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,9 @@ 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, which unregisters its tools and hooks.
77
+ Settings live under `sessions` in `~/.pi/agent/settings.json`. A project's `.pi/settings.json` overrides them the way pi merges its own settings: nested objects merge key by key, and the project wins.
78
+
79
+ 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
80
 
80
81
  ```json
81
82
  {
@@ -200,6 +201,7 @@ If you want to override the shortcut, put this in your `~/.pi/agent/settings.jso
200
201
  "thinkingLevel": "low",
201
202
  "roster": ["anthropic/*", "openai-codex/gpt-5.6-terra:high"],
202
203
  "deferred": {
204
+ "enable": true,
203
205
  "copyToClipboard": true
204
206
  }
205
207
  }
@@ -211,6 +213,8 @@ If you want to override the shortcut, put this in your `~/.pi/agent/settings.jso
211
213
 
212
214
  `roster` limits which models a handoff may launch a child session on. Defaults to pi's own `enabledModels` scoping, then to all configured models. May optionally include a thinking level; listing a model more than once adds to the levels it allows.
213
215
 
216
+ `deferred.enable` (default `true`) offers the `deferred` launch. Turn it off, along with `sessions.subagents.enable`, to leave only host and split launches.
217
+
214
218
  `deferred.copyToClipboard` (default `true`) controls whether deferred handoffs copy the resume command to the clipboard. When off, the resume command is only shown in the tool call.
215
219
 
216
220
  Subagents require the handoff and messaging features. Limit recursive delegation depth with `sessions.subagents.maxDepth` (default `2`), or cap the context a subagent runs in with `sessions.subagents.contextLimit`:
@@ -230,12 +234,11 @@ Subagents require the handoff and messaging features. Limit recursive delegation
230
234
 
231
235
  ## Session Index
232
236
 
233
- By default, `pi-sessions` will start indexing all conversations moving forward. If you want to backfill all prior conversations:
237
+ 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
238
 
235
- - run `/session-index`
236
- - hit `r` to (re)index everything
239
+ 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
240
 
238
- this is idempotent, so if you run into any issues, or disable pi-sessions for a while, feel free to re-index to see if that resolves anything.
241
+ 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
242
 
240
243
  By default the index lives at:
241
244
 
@@ -156,9 +156,7 @@ export default function piSessions(pi: ExtensionAPI): void {
156
156
  }),
157
157
  );
158
158
  }
159
- if (settings.features.hooks) {
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
@@ -28,7 +28,11 @@ import {
28
28
  } from "./tool.ts";
29
29
  import { HANDOFF_TOOL_DETAILS_SCHEMA } from "./tool-contract.ts";
30
30
  import { HandoffToolComponent } from "./tool-renderer.ts";
31
- import { buildHandoffLaunchSchema, buildHandoffPromptGuidelines } from "./tool-schema.ts";
31
+ import {
32
+ buildHandoffLaunchSchema,
33
+ buildHandoffPromptGuidelines,
34
+ offersSubagentLaunch,
35
+ } from "./tool-schema.ts";
32
36
  import { buildHandoffToolView } from "./tool-view-model.ts";
33
37
  import { formatHandoffError } from "./ui.ts";
34
38
 
@@ -66,14 +70,16 @@ export function installHandoff(
66
70
  launchTargets: readonly HandoffLaunchTarget[],
67
71
  roster: HandoffRoster | undefined,
68
72
  ): void {
73
+ const offersSubagent = offersSubagentLaunch(launchTargets);
69
74
  pi.registerTool({
70
75
  name: "session_handoff",
71
76
  label: "Session Handoff",
72
77
  description: "Start a new Pi session with a self-contained task.",
73
78
  // Nested calls write no toolResult entry, so the board would never see the launch receipt.
74
79
  exposure: "model-only",
75
- promptSnippet:
76
- "Delegate bounded work to a background subagent or hand off context to another Pi session",
80
+ promptSnippet: offersSubagent
81
+ ? "Delegate bounded work to a background subagent or hand off context to another Pi session"
82
+ : "Hand off context to another Pi session",
77
83
  promptGuidelines: buildHandoffPromptGuidelines(launchTargets, models, roster),
78
84
  parameters: Type.Object({
79
85
  goal: Type.String({
@@ -93,8 +99,7 @@ export function installHandoff(
93
99
  ),
94
100
  requestResponse: Type.Optional(
95
101
  Type.Boolean({
96
- description:
97
- "Whether the child session should report completion/results of its task back to this session. Defaults to true for subagent launches and false otherwise.",
102
+ description: `Whether the child session should report completion/results of its task back to this session. ${offersSubagent ? "Defaults to true for subagent launches and false otherwise." : "Defaults to false."}`,
98
103
  }),
99
104
  ),
100
105
  provider: Type.Optional(
@@ -111,8 +116,9 @@ export function installHandoff(
111
116
  Type.Union(
112
117
  THINKING_LEVELS.map((level) => Type.Literal(level)),
113
118
  {
114
- description:
115
- "Thinking level for the child session. For directional or deferred, override only when the user requests it.",
119
+ description: offersSubagent
120
+ ? "Thinking level for the child session. For launches other than subagent, override only when the user requests it."
121
+ : "Thinking level for the child session. Override only when the user requests it.",
116
122
  },
117
123
  ),
118
124
  ),
@@ -215,7 +221,7 @@ export function installHandoff(
215
221
  createHandoffLaunchTargets({
216
222
  pi,
217
223
  splitBackend,
218
- copyDeferredToClipboard: settings.handoff.deferred.copyToClipboard,
224
+ deferred: settings.handoff.deferred,
219
225
  additionalTargets: deps.getLaunchTargets?.() ?? [],
220
226
  hosts: deps.getHosts?.() ?? [],
221
227
  }),
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { Host } from "../hosts/contract.ts";
3
+ import type { HandoffSettings } from "../shared/settings.ts";
3
4
  import { createDeferredLaunchBackend } from "./launch/deferred.ts";
4
5
  import type { SplitLaunchBackend } from "./launch/resolution.ts";
5
6
  import {
@@ -18,7 +19,7 @@ export interface ResolvedHandoffLaunchTarget {
18
19
  export function createHandoffLaunchTargets(options: {
19
20
  pi: ExtensionAPI;
20
21
  splitBackend: SplitLaunchBackend | undefined;
21
- copyDeferredToClipboard: boolean;
22
+ deferred: HandoffSettings["deferred"];
22
23
  additionalTargets: readonly HandoffLaunchTarget[];
23
24
  hosts?: readonly Host[];
24
25
  }): HandoffLaunchTarget[] {
@@ -35,11 +36,15 @@ export function createHandoffLaunchTargets(options: {
35
36
  ),
36
37
  )
37
38
  : [];
38
- const deferred = createBackendLaunchTarget(
39
- DEFERRED_LAUNCH,
40
- createDeferredLaunchBackend({ copyToClipboard: options.copyDeferredToClipboard }),
41
- "'deferred' creates the session and returns its resume command without opening anything.",
42
- );
39
+ const deferredTargets = options.deferred.enable
40
+ ? [
41
+ createBackendLaunchTarget(
42
+ DEFERRED_LAUNCH,
43
+ createDeferredLaunchBackend({ copyToClipboard: options.deferred.copyToClipboard }),
44
+ "'deferred' creates the session and returns its resume command without opening anything.",
45
+ ),
46
+ ]
47
+ : [];
43
48
  return [
44
49
  ...hosts.map((host) =>
45
50
  createBackendLaunchTarget(
@@ -50,7 +55,7 @@ export function createHandoffLaunchTargets(options: {
50
55
  ),
51
56
  ),
52
57
  ...splitTargets,
53
- deferred,
58
+ ...deferredTargets,
54
59
  ...options.additionalTargets,
55
60
  ];
56
61
  }
@@ -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 missing or incompatible",
316
- description: "Run /session-index to rebuild it.",
316
+ title: "Session index unavailable",
317
+ description: formatRequiredSessionIndexError(indexPath),
317
318
  };
318
319
  }
319
320
 
@@ -1,6 +1,11 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import { Type } from "typebox";
3
- import { type HandoffLaunchTarget, LAUNCH_DIRECTIONS, SUBAGENT_LAUNCH } from "./launch-target.ts";
3
+ import {
4
+ DEFERRED_LAUNCH,
5
+ type HandoffLaunchTarget,
6
+ LAUNCH_DIRECTIONS,
7
+ SUBAGENT_LAUNCH,
8
+ } from "./launch-target.ts";
4
9
  import { formatRosterEntry, type HandoffRoster } from "./roster.ts";
5
10
 
6
11
  export function buildHandoffLaunchSchema(targets: readonly HandoffLaunchTarget[]) {
@@ -23,9 +28,6 @@ const SUBAGENT_GUIDELINES = [
23
28
  "After launching a subagent, do not sleep, poll, or repeatedly check its progress; continue useful work or finish the current turn and wait for its report.",
24
29
  ];
25
30
 
26
- const LAUNCH_TARGET_GUIDELINE =
27
- "Use session_handoff directional or deferred launches only when the user requests one.";
28
-
29
31
  const MODEL_INHERITANCE_GUIDELINES = [
30
32
  "Leave provider and model unset to run the handoff on this session's current model.",
31
33
  "To run the handoff on a different model, set both provider and model together (both are required).",
@@ -35,7 +37,7 @@ const RESTRICTED_THINKING_GUIDELINE =
35
37
  'A ":level" suffix on a listed model limits it to those thinking levels; set thinkingLevel to one of them, or leave it unset when only one is listed.';
36
38
 
37
39
  const SUBAGENT_MODEL_OVERRIDE_GUIDELINE =
38
- "For subagents, choose the model that best fits the delegated task; for directional or deferred handoffs, only override the model when the task clearly warrants it. When unsure, ask the user for their preference.";
40
+ "For subagents, choose the model that best fits the delegated task; for other launches, only override the model when the task clearly warrants it. When unsure, ask the user for their preference.";
39
41
 
40
42
  const DEFAULT_MODEL_OVERRIDE_GUIDELINE =
41
43
  "Only override the model when the task clearly warrants it.";
@@ -45,20 +47,15 @@ export function buildHandoffPromptGuidelines(
45
47
  models: readonly Model<Api>[],
46
48
  roster: HandoffRoster | undefined,
47
49
  ): string[] {
48
- const hasSubagent = targets.some((target) => target.value === SUBAGENT_LAUNCH);
49
- const hasHost = targets.some(
50
- (target) =>
51
- target.value !== "subagent" &&
52
- target.value !== "deferred" &&
53
- !LAUNCH_DIRECTIONS.some((direction) => direction === target.value),
54
- );
50
+ const hasSubagent = offersSubagentLaunch(targets);
51
+ const userFacingKinds = describeUserFacingLaunchKinds(targets);
55
52
  const selectable =
56
53
  roster?.map(formatRosterEntry) ?? models.map((model) => `${model.provider}/${model.id}`);
57
54
  return [
58
55
  ...(hasSubagent ? SUBAGENT_GUIDELINES : []),
59
- hasHost
60
- ? "Use session_handoff host or deferred launches only when the user requests one."
61
- : LAUNCH_TARGET_GUIDELINE,
56
+ ...(userFacingKinds
57
+ ? [`Use session_handoff ${userFacingKinds} launches only when the user requests one.`]
58
+ : []),
62
59
  ...MODEL_INHERITANCE_GUIDELINES,
63
60
  hasSubagent ? SUBAGENT_MODEL_OVERRIDE_GUIDELINE : DEFAULT_MODEL_OVERRIDE_GUIDELINE,
64
61
  ...(selectable.length > 0
@@ -67,3 +64,24 @@ export function buildHandoffPromptGuidelines(
67
64
  ...(roster?.some((entry) => entry.thinkingLevels) ? [RESTRICTED_THINKING_GUIDELINE] : []),
68
65
  ];
69
66
  }
67
+
68
+ export function offersSubagentLaunch(targets: readonly HandoffLaunchTarget[]): boolean {
69
+ return targets.some((target) => target.value === SUBAGENT_LAUNCH);
70
+ }
71
+
72
+ function describeUserFacingLaunchKinds(targets: readonly HandoffLaunchTarget[]): string {
73
+ const isDirection = (value: string) => LAUNCH_DIRECTIONS.some((direction) => direction === value);
74
+ const kinds = [
75
+ targets.some((target) => isDirection(target.value)) ? "directional" : undefined,
76
+ targets.some(
77
+ (target) =>
78
+ target.value !== SUBAGENT_LAUNCH &&
79
+ target.value !== DEFERRED_LAUNCH &&
80
+ !isDirection(target.value),
81
+ )
82
+ ? "host"
83
+ : undefined,
84
+ targets.some((target) => target.value === DEFERRED_LAUNCH) ? "deferred" : undefined,
85
+ ];
86
+ return kinds.filter((kind) => kind !== undefined).join(" or ");
87
+ }
@@ -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(pi: ExtensionAPI, deps: { index: IndexHandle }): SessionLifecycle {
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 { type ReindexResult, rebuildSessionIndex } from "../session-search/reindex.ts";
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(pi: ExtensionAPI, deps: { settings: SessionSettings }): IndexHandle {
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 { path: indexPath };
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: options.mode,
91
+ mode: "read",
79
92
  ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
80
93
  });
81
- const status = getIndexStatusFromDb(indexPath, db);
82
- if (status.schemaVersion !== INDEX_SCHEMA_VERSION) {
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 `Session index missing or incompatible at ${indexPath}. Run /session-index and press r to rebuild it.`;
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, { create, readonly, timeoutMs: options?.timeoutMs });
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: { create: boolean; readonly?: boolean | undefined; timeoutMs?: number | undefined },
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
- db.exec(`PRAGMA busy_timeout = ${options.timeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS}`);
70
- db.exec("PRAGMA foreign_keys = ON");
71
-
72
- if (!readonly) {
73
- // journal_mode is the connection's first write-capable lock acquisition (and
74
- // may run WAL recovery), and immediate transactions rely on busy_timeout to
75
- // queue behind concurrent writers instead of failing.
76
- db.exec("PRAGMA journal_mode = WAL");
77
- db.exec("PRAGMA synchronous = NORMAL");
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 "../shared/session-index/index.ts";
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
- const stat = statSync(sessionFile);
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
- const tail = extractSessionTail(sessionFile, baseline);
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
- const extracted = extractSessionRecord(sessionFile);
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
  }
@@ -35,6 +35,7 @@ const SESSION_FILE_SETTINGS_SCHEMA = Type.Object({
35
35
  roster: Type.Optional(Type.Array(Type.String())),
36
36
  deferred: Type.Optional(
37
37
  Type.Object({
38
+ enable: Type.Optional(Type.Boolean()),
38
39
  copyToClipboard: Type.Optional(Type.Boolean()),
39
40
  }),
40
41
  ),
@@ -103,6 +104,7 @@ export interface HandoffSettings extends AgentModelSettings {
103
104
  /** Patterns naming the models a handoff may launch a child on. Empty means unrestricted. */
104
105
  roster: readonly ModelSelection[];
105
106
  deferred: {
107
+ enable: boolean;
106
108
  copyToClipboard: boolean;
107
109
  };
108
110
  }
@@ -228,9 +230,14 @@ function normalizeAutoTitlePrompt(value: string | undefined): string {
228
230
  return trimmed ? trimmed : DEFAULT_AUTO_TITLE_PROMPT;
229
231
  }
230
232
 
233
+ /**
234
+ * pi's merged view: project `.pi/settings.json` deep-merged over the global file.
235
+ * Extensions load before `pi.getSettings()` is bound, and the trust decision it
236
+ * honors is not visible at load time, so project settings apply regardless of trust.
237
+ */
231
238
  function loadSessionFileSettings(): SessionFileSettings {
232
- const globalSettings = SettingsManager.create(process.cwd()).getGlobalSettings();
233
- const parsed = parseTypeBoxValue(ROOT_SETTINGS_SCHEMA, globalSettings, "Invalid settings");
239
+ const mergedSettings = SettingsManager.create(process.cwd()).getSettings();
240
+ const parsed = parseTypeBoxValue(ROOT_SETTINGS_SCHEMA, mergedSettings, "Invalid settings");
234
241
  return parsed.sessions ?? {};
235
242
  }
236
243
 
@@ -263,6 +270,7 @@ function resolveSessionSettings(fileSettings: SessionFileSettings): SessionSetti
263
270
  persistRuns: fileSettings.handoff?.persistRuns ?? false,
264
271
  roster: normalizeRoster(fileSettings.handoff?.roster),
265
272
  deferred: {
273
+ enable: fileSettings.handoff?.deferred?.enable ?? true,
266
274
  copyToClipboard: fileSettings.handoff?.deferred?.copyToClipboard ?? true,
267
275
  },
268
276
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sessions",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "Pi session search, ask, handoff, messaging, auto-titling, and indexing tools",
6
6
  "license": "MIT",
@@ -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
- }