@tekmidian/pai 0.30.1 → 0.32.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.
Files changed (44) hide show
  1. package/dist/cli/index.mjs +3 -3
  2. package/dist/cli/program.mjs +3 -3
  3. package/dist/daemon/index.mjs +3 -3
  4. package/dist/{daemon-mFGnPd8q.mjs → daemon-iNuNMMKd.mjs} +5 -5
  5. package/dist/{daemon-mFGnPd8q.mjs.map → daemon-iNuNMMKd.mjs.map} +1 -1
  6. package/dist/{factory-BqAdO21B.mjs → factory-BydJSrZJ.mjs} +13 -2
  7. package/dist/factory-BydJSrZJ.mjs.map +1 -0
  8. package/dist/hooks/capture-all-events.mjs.map +1 -1
  9. package/dist/hooks/cleanup-session-files.mjs +21 -15
  10. package/dist/hooks/cleanup-session-files.mjs.map +2 -2
  11. package/dist/hooks/context-compression-hook.mjs +6 -6
  12. package/dist/hooks/context-compression-hook.mjs.map +3 -3
  13. package/dist/hooks/initialize-session.mjs.map +1 -1
  14. package/dist/hooks/inject-observations.mjs.map +1 -1
  15. package/dist/hooks/load-core-context.mjs.map +1 -1
  16. package/dist/hooks/load-project-context.mjs +43 -25
  17. package/dist/hooks/load-project-context.mjs.map +3 -3
  18. package/dist/hooks/observe.mjs.map +1 -1
  19. package/dist/hooks/stop-hook.mjs +27 -21
  20. package/dist/hooks/stop-hook.mjs.map +3 -3
  21. package/dist/hooks/sync-todo-to-md.mjs +2 -2
  22. package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
  23. package/dist/{main-resolver-BfSL8zio.mjs → main-resolver-DjyUDJrv.mjs} +424 -58
  24. package/dist/main-resolver-DjyUDJrv.mjs.map +1 -0
  25. package/dist/{pick-BcmBBfOF.mjs → pick-DlsM0ppq.mjs} +817 -247
  26. package/dist/pick-DlsM0ppq.mjs.map +1 -0
  27. package/dist/{work-queue-worker-B8QUONlK.mjs → work-queue-worker-BAgwmMDl.mjs} +55 -14
  28. package/dist/work-queue-worker-BAgwmMDl.mjs.map +1 -0
  29. package/docs/commands/README.md +5 -0
  30. package/docs/commands/project.md +38 -0
  31. package/docs/commands/projects.md +38 -0
  32. package/docs/commands/session.md +17 -0
  33. package/package.json +1 -1
  34. package/src/hooks/ts/lib/project-utils/index.ts +1 -1
  35. package/src/hooks/ts/lib/project-utils/paths.test.ts +125 -0
  36. package/src/hooks/ts/lib/project-utils/paths.ts +68 -14
  37. package/src/hooks/ts/lib/project-utils.ts +1 -1
  38. package/src/hooks/ts/session-start/load-project-context.ts +34 -31
  39. package/src/hooks/ts/stop/stop-hook.ts +5 -5
  40. package/src/hooks/ts/user-prompt/cleanup-session-files.ts +19 -6
  41. package/dist/factory-BqAdO21B.mjs.map +0 -1
  42. package/dist/main-resolver-BfSL8zio.mjs.map +0 -1
  43. package/dist/pick-BcmBBfOF.mjs.map +0 -1
  44. package/dist/work-queue-worker-B8QUONlK.mjs.map +0 -1
@@ -20,6 +20,8 @@ pai projects <subcommand> [options]
20
20
  | [`pai projects add <path>`](#pai-projects-add-path) | Register a project directory in the PAI registry |
21
21
  | [`pai projects info <slug>`](#pai-projects-info-slug) | Show full details for a project |
22
22
  | [`pai projects archive <slug>`](#pai-projects-archive-slug) | Archive a project |
23
+ | [`pai projects merge <from> <into>`](#pai-projects-merge-from-into) | Fold a duplicate project into another: move its sessions (renumbered), repoint its tags, aliases, compaction records and links, keep the old slug as an alias, then delete the row. Preview unless --execute is given. |
24
+ | [`pai projects unregister <slug>`](#pai-projects-unregister-slug) | Remove a project row entirely, for paths that should never have been registered (worktrees, temp dirs). Refuses when the row holds sessions — merge those first. Preview unless --execute is given. The directory itself is never touched. |
23
25
  | [`pai projects unarchive <slug>`](#pai-projects-unarchive-slug) | Restore an archived project to active status |
24
26
  | [`pai projects move <slug> <new-path>`](#pai-projects-move-slug-new-path) | Update the root path for a project |
25
27
  | [`pai projects rebind <slug> <new-path>`](#pai-projects-rebind-slug-new-path) | Manually update the root_path for a project (for when auto-detect found multiple matches). |
@@ -119,6 +121,42 @@ Archive a project
119
121
  | `<slug>` | required |
120
122
 
121
123
 
124
+ ### pai projects merge <from> <into>
125
+
126
+ Fold a duplicate project into another: move its sessions (renumbered), repoint its tags, aliases, compaction records and links, keep the old slug as an alias, then delete the row. Preview unless --execute is given.
127
+
128
+ **Arguments**
129
+
130
+ | Argument | Kind |
131
+ |----------|------|
132
+ | `<from>` | required |
133
+ | `<into>` | required |
134
+
135
+ **Options**
136
+
137
+ | Option | Description | Default |
138
+ |--------|-------------|---------|
139
+ | `--execute` | Actually perform the merge | |
140
+
141
+
142
+ ### pai projects unregister <slug>
143
+
144
+ Remove a project row entirely, for paths that should never have been registered (worktrees, temp dirs). Refuses when the row holds sessions — merge those first. Preview unless --execute is given. The directory itself is never touched.
145
+
146
+ **Arguments**
147
+
148
+ | Argument | Kind |
149
+ |----------|------|
150
+ | `<slug>` | required |
151
+
152
+ **Options**
153
+
154
+ | Option | Description | Default |
155
+ |--------|-------------|---------|
156
+ | `--execute` | Actually remove the row | |
157
+ | `--force` | Remove even though sessions would be deleted with it | |
158
+
159
+
122
160
  ### pai projects unarchive <slug>
123
161
 
124
162
  Restore an archived project to active status
@@ -17,6 +17,7 @@ pai session <subcommand> [options]
17
17
  | Command | Description |
18
18
  |---------|-------------|
19
19
  | [`pai session list`](#pai-session-list) | Resumable sessions catalog — named sessions with resume status. |
20
+ | [`pai session restore`](#pai-session-restore) | Restore transcripts PAI displaced into sessions/ so claude --resume can find them again. Reports what is unresumable, which checkpoints promise it, and which transcripts are empty. Dry run unless --execute is given. |
20
21
  | [`pai session info <project-slug> <number>`](#pai-session-info-project-slug-number) | Show full details for a specific session |
21
22
  | [`pai session rename <project-slug> <number> <new-slug>`](#pai-session-rename-project-slug-number-new-slug) | Rename a session note — updates file on disk, H1 title, and registry |
22
23
  | [`pai session slug <project-slug> <number>`](#pai-session-slug-project-slug-number) | Generate a descriptive slug from the session JSONL transcript |
@@ -49,6 +50,22 @@ Use --all to also show unnamed orphan sessions.
49
50
  | `--json` | Output raw JSON instead of formatted table | |
50
51
 
51
52
 
53
+ ### pai session restore
54
+
55
+ Restore transcripts PAI displaced into sessions/ so claude --resume can find them again. Reports what is unresumable, which checkpoints promise it, and which transcripts are empty. Dry run unless --execute is given.
56
+
57
+ **Options**
58
+
59
+ | Option | Description | Default |
60
+ |--------|-------------|---------|
61
+ | `--execute` | Actually restore (hardlink back to the project root) | |
62
+ | `--promised` | Only sessions a checkpoint tells you to resume | |
63
+ | `--cwd <path>` | Only sessions belonging to this working directory | |
64
+ | `--all` | List every displaced session, not just the largest few | |
65
+ | `--include-stubs` | Also restore transcripts that hold no conversation | |
66
+ | `--json` | Output raw JSON instead of a formatted report | |
67
+
68
+
52
69
  ### pai session info <project-slug> <number>
53
70
 
54
71
  Show full details for a specific session
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekmidian/pai",
3
- "version": "0.30.1",
3
+ "version": "0.32.0",
4
4
  "description": "PAI Knowledge OS — Personal AI Infrastructure with federated memory and project management",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -17,7 +17,7 @@ export {
17
17
  ensureNotesDirSmart,
18
18
  ensureSessionsDir,
19
19
  ensureSessionsDirFromProjectDir,
20
- moveSessionFilesToSessionsDir,
20
+ archiveSessionFilesToSessionsDir,
21
21
  findTodoPath,
22
22
  findClaudeMdPath,
23
23
  findAllClaudeMdPaths,
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ mkdtempSync,
4
+ rmSync,
5
+ mkdirSync,
6
+ writeFileSync,
7
+ existsSync,
8
+ statSync,
9
+ readFileSync,
10
+ } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { archiveSessionFilesToSessionsDir } from "./paths.js";
14
+
15
+ /**
16
+ * The archiver is the function that destroyed users' sessions.
17
+ *
18
+ * It used to renameSync transcripts out of the project root into sessions/, and
19
+ * `claude --resume <uuid>` only finds them at the root. Measured 2026-08-04:
20
+ * resuming an 867 KB transcript that lived only in sessions/ answered
21
+ * "No conversation found with session ID". It ran from a UserPromptSubmit hook
22
+ * excluding only the current session, so every prompt anyone typed unresumed
23
+ * every other session in the project — one project measured 1 transcript at the
24
+ * root against 52 underneath.
25
+ *
26
+ * So the assertion that matters in every case below is the same one, and it is
27
+ * about the SOURCE, not the destination: the root file must still be there.
28
+ */
29
+
30
+ let projectDir: string;
31
+ const sessions = () => join(projectDir, "sessions");
32
+ const A = "aaaaaaaa-1111-4111-8111-111111111111.jsonl";
33
+ const B = "bbbbbbbb-2222-4222-8222-222222222222.jsonl";
34
+
35
+ beforeEach(() => {
36
+ projectDir = mkdtempSync(join(tmpdir(), "pai-paths-"));
37
+ });
38
+
39
+ afterEach(() => {
40
+ rmSync(projectDir, { recursive: true, force: true });
41
+ });
42
+
43
+ describe("archiving a transcript never removes it from the project root", () => {
44
+ it("leaves the source in place — the whole bug in one assertion", () => {
45
+ writeFileSync(join(projectDir, A), '{"type":"user"}\n');
46
+
47
+ expect(archiveSessionFilesToSessionsDir(projectDir, undefined, true)).toBe(1);
48
+
49
+ expect(existsSync(join(projectDir, A))).toBe(true); // resume needs THIS one
50
+ expect(existsSync(join(sessions(), A))).toBe(true); // consumers need this one
51
+ });
52
+
53
+ it("links rather than copies, so the archive cannot drift or cost space", () => {
54
+ writeFileSync(join(projectDir, A), '{"type":"user"}\n');
55
+ archiveSessionFilesToSessionsDir(projectDir, undefined, true);
56
+
57
+ const root = statSync(join(projectDir, A));
58
+ const archived = statSync(join(sessions(), A));
59
+ expect(archived.ino).toBe(root.ino);
60
+ expect(archived.nlink).toBeGreaterThanOrEqual(2);
61
+ });
62
+
63
+ it("keeps the archive current as the live transcript grows", () => {
64
+ // One inode means appends are visible through both names. A copy would
65
+ // freeze the archive at archive time, and session-summary-worker reads the
66
+ // archive to write session notes — it would summarise a truncated session.
67
+ writeFileSync(join(projectDir, A), '{"type":"user"}\n');
68
+ archiveSessionFilesToSessionsDir(projectDir, undefined, true);
69
+ writeFileSync(join(projectDir, A), '{"type":"user"}\n{"type":"assistant"}\n');
70
+
71
+ expect(readFileSync(join(sessions(), A), "utf8")).toContain("assistant");
72
+ });
73
+ });
74
+
75
+ describe("what it archives", () => {
76
+ it("archives every transcript in the root", () => {
77
+ writeFileSync(join(projectDir, A), "a");
78
+ writeFileSync(join(projectDir, B), "b");
79
+
80
+ expect(archiveSessionFilesToSessionsDir(projectDir, undefined, true)).toBe(2);
81
+ expect(existsSync(join(projectDir, A))).toBe(true);
82
+ expect(existsSync(join(projectDir, B))).toBe(true);
83
+ });
84
+
85
+ it("skips the excluded live session", () => {
86
+ writeFileSync(join(projectDir, A), "a");
87
+ writeFileSync(join(projectDir, B), "b");
88
+
89
+ expect(archiveSessionFilesToSessionsDir(projectDir, B, true)).toBe(1);
90
+ expect(existsSync(join(sessions(), A))).toBe(true);
91
+ expect(existsSync(join(sessions(), B))).toBe(false);
92
+ expect(existsSync(join(projectDir, B))).toBe(true);
93
+ });
94
+
95
+ it("ignores files that are not transcripts", () => {
96
+ writeFileSync(join(projectDir, "notes.md"), "x");
97
+ expect(archiveSessionFilesToSessionsDir(projectDir, undefined, true)).toBe(0);
98
+ });
99
+
100
+ it("is idempotent, and does not disturb what is already archived", () => {
101
+ // The hooks call this on every prompt and at every session end, so running
102
+ // twice is the normal case, not an edge case.
103
+ writeFileSync(join(projectDir, A), "a");
104
+ expect(archiveSessionFilesToSessionsDir(projectDir, undefined, true)).toBe(1);
105
+ expect(archiveSessionFilesToSessionsDir(projectDir, undefined, true)).toBe(0);
106
+ expect(existsSync(join(projectDir, A))).toBe(true);
107
+ });
108
+
109
+ it("leaves an already-archived transcript alone rather than relinking it", () => {
110
+ // A transcript archived by the OLD destructive version: present underneath,
111
+ // absent from the root. Restoring those is a separate repair (launch.ts),
112
+ // and this must not clobber the archived copy while that is pending.
113
+ mkdirSync(sessions(), { recursive: true });
114
+ writeFileSync(join(sessions(), A), "archived-by-the-old-rename");
115
+
116
+ expect(archiveSessionFilesToSessionsDir(projectDir, undefined, true)).toBe(0);
117
+ expect(readFileSync(join(sessions(), A), "utf8")).toBe("archived-by-the-old-rename");
118
+ });
119
+
120
+ it("returns 0 for a project directory that does not exist", () => {
121
+ expect(
122
+ archiveSessionFilesToSessionsDir(join(projectDir, "nope"), undefined, true)
123
+ ).toBe(0);
124
+ });
125
+ });
@@ -2,7 +2,7 @@
2
2
  * Path utilities — encoding, Notes/Sessions directory discovery and creation.
3
3
  */
4
4
 
5
- import { existsSync, mkdirSync, readdirSync, renameSync } from 'fs';
5
+ import { existsSync, mkdirSync, readdirSync, linkSync, copyFileSync } from 'fs';
6
6
  import { join, basename } from 'path';
7
7
  import { PAI_DIR } from '../pai-paths.js';
8
8
 
@@ -137,10 +137,43 @@ export function ensureSessionsDirFromProjectDir(projectDir: string): string {
137
137
  }
138
138
 
139
139
  /**
140
- * Move all .jsonl session files from project root to sessions/ subdirectory.
141
- * Returns the number of files moved.
140
+ * Publish every project-root .jsonl into sessions/ as well, WITHOUT removing it.
141
+ *
142
+ * This used to renameSync, and that is how PAI destroyed its users' sessions.
143
+ *
144
+ * `claude --resume <uuid>` finds a transcript only at the project root. Move it
145
+ * into sessions/ and the session becomes permanently unresumable — measured
146
+ * 2026-08-04: `claude --resume b3462801` (867 KB of real work, sessions/ only)
147
+ * answers "No conversation found with session ID", while a top-level id is found
148
+ * fine. Nothing warned; the id still looked valid everywhere PAI displayed it.
149
+ *
150
+ * The damage was not occasional. This ran from a UserPromptSubmit hook excluding
151
+ * only the CURRENT session, so every prompt anyone typed unresumed every other
152
+ * session in the project. One PAI project measured 1 transcript at top level
153
+ * against 52 underneath. Among the casualties was 046bb712 — the exact id PAI's
154
+ * own handover tells the user to resume.
155
+ *
156
+ * A hardlink satisfies both sides, which is why this is a two-line fix rather
157
+ * than a redesign: the archive genuinely has consumers that read sessions/
158
+ * (session-summary-worker, registry/moved, session/autosave), and `--resume`
159
+ * needs the root path. One inode, two names, no copy, no window where the file
160
+ * is missing from either place.
161
+ *
162
+ * Never unlink the source. Tidying up another tool's store was the whole
163
+ * mistake; a stale duplicate is free, a lost session is not. Every caller of
164
+ * `transcriptFiles()` was checked before choosing this — they all test emptiness
165
+ * (`.length > 0`), never count, so the duplicate cannot skew a project's stats.
166
+ *
167
+ * `excludeFile` keeps a hook from archiving the transcript it is itself watching
168
+ * being written. It is not a "finished sessions only" guard and must not be read
169
+ * as one: it excludes exactly one file, the caller's own, so with two sessions
170
+ * live in one project each still archives the other mid-turn. A consumer that
171
+ * needs "finished only" has to enforce that itself — this function cannot know
172
+ * what is live.
173
+ *
174
+ * Returns the number of files newly archived.
142
175
  */
143
- export function moveSessionFilesToSessionsDir(
176
+ export function archiveSessionFilesToSessionsDir(
144
177
  projectDir: string,
145
178
  excludeFile?: string,
146
179
  silent = false
@@ -150,25 +183,46 @@ export function moveSessionFilesToSessionsDir(
150
183
  if (!existsSync(projectDir)) return 0;
151
184
 
152
185
  const files = readdirSync(projectDir);
153
- let movedCount = 0;
186
+ let archivedCount = 0;
154
187
 
155
188
  for (const file of files) {
156
- if (file.endsWith('.jsonl') && file !== excludeFile) {
157
- const sourcePath = join(projectDir, file);
158
- const destPath = join(sessionsDir, file);
189
+ if (!file.endsWith('.jsonl') || file === excludeFile) continue;
190
+
191
+ const sourcePath = join(projectDir, file);
192
+ const destPath = join(sessionsDir, file);
193
+
194
+ // Already archived — including by an earlier rename, before this was a
195
+ // hardlink. Those are the sessions that need restoring to the root, which is
196
+ // a separate repair and not this function's job.
197
+ if (existsSync(destPath)) continue;
198
+
199
+ try {
200
+ linkSync(sourcePath, destPath);
201
+ if (!silent) console.error(`Archived ${file} → sessions/ (still resumable)`);
202
+ archivedCount++;
203
+ } catch (error) {
204
+ // Cross-device (EXDEV) is the realistic failure: sessions/ on another
205
+ // volume. Copy instead, and still leave the original alone.
159
206
  try {
160
- renameSync(sourcePath, destPath);
161
- if (!silent) console.error(`Moved ${file} → sessions/`);
162
- movedCount++;
163
- } catch (error) {
164
- if (!silent) console.error(`Could not move ${file}: ${error}`);
207
+ copyFileSync(sourcePath, destPath);
208
+ if (!silent) console.error(`Copied ${file} → sessions/ (hardlink unavailable)`);
209
+ archivedCount++;
210
+ } catch {
211
+ if (!silent) console.error(`Could not archive ${file}: ${error}`);
165
212
  }
166
213
  }
167
214
  }
168
215
 
169
- return movedCount;
216
+ return archivedCount;
170
217
  }
171
218
 
219
+ /**
220
+ * @deprecated Renamed to `archiveSessionFilesToSessionsDir`, which is what it
221
+ * now does. Kept so an out-of-tree caller fails loudly at the type level rather
222
+ * than silently keeping the old destructive name for a non-destructive action.
223
+ */
224
+ export const moveSessionFilesToSessionsDir = archiveSessionFilesToSessionsDir;
225
+
172
226
  // ---------------------------------------------------------------------------
173
227
  // CLAUDE.md / TODO.md discovery
174
228
  // ---------------------------------------------------------------------------
@@ -17,7 +17,7 @@ export {
17
17
  ensureNotesDirSmart,
18
18
  ensureSessionsDir,
19
19
  ensureSessionsDirFromProjectDir,
20
- moveSessionFilesToSessionsDir,
20
+ archiveSessionFilesToSessionsDir,
21
21
  findTodoPath,
22
22
  findClaudeMdPath,
23
23
  findAllClaudeMdPaths,
@@ -31,7 +31,8 @@ import {
31
31
  findTodoPath,
32
32
  findAllClaudeMdPaths,
33
33
  sendNtfyNotification,
34
- isProbeSession
34
+ isProbeSession,
35
+ archiveSessionFilesToSessionsDir
35
36
  } from '../lib/project-utils';
36
37
 
37
38
  /**
@@ -244,40 +245,42 @@ async function main() {
244
245
  }
245
246
  }
246
247
 
247
- // 3. Cleanup old .jsonl files from project root (move to sessions/)
248
- // Keep the newest one for potential resume, move older ones to sessions/
248
+ // 3. Archive the project's transcripts into sessions/ by LINKING, never moving.
249
+ //
250
+ // This used to renameSync every .jsonl except the newest out of the project
251
+ // root. `claude --resume <uuid>` reads ~/.claude/projects/<encoded>/<uuid>.jsonl
252
+ // and only that path, so moving the file is what makes a session unresumable —
253
+ // and because this is a SessionStart hook, the act of STARTING a session in a
254
+ // project destroyed the resumability of every earlier session in it. The old
255
+ // comment ("keep the newest one for potential resume") shows the dependency was
256
+ // known; keeping one file was not enough.
257
+ //
258
+ // Measured 2026-08-04: this repo had 1 transcript at the top level and 52 under
259
+ // sessions/, every one of the 52 unresumable. Among them was the id PAI prints
260
+ // in its own archived handovers as `claude --resume <uuid>`, so the instruction
261
+ // we ship in every checkpoint could not work.
262
+ //
263
+ // It also completes the incident of that morning: `pai Paperfull` failed to
264
+ // resume b3462801 because of a probe bug, started a fresh session instead, and
265
+ // THIS hook then moved b3462801 — 867 KB of real work — out of reach. The
266
+ // failed resume destroyed what it had failed to open.
267
+ //
268
+ // A hard link keeps both truths: the archive under sessions/ is populated for
269
+ // everything that reads it, and the file Claude Code owns stays where Claude
270
+ // Code put it. Same inode, so it costs nothing.
271
+ // This was the fourth mover, with its own inline loop. It now calls the one
272
+ // shared archiver instead: a second implementation is exactly how the earlier
273
+ // probeResume fix came to land in one of three copies and leave `pai <Name>`
274
+ // broken for a day.
249
275
  const projectDir = getProjectDir(cwd);
250
276
  if (existsSync(projectDir)) {
251
277
  try {
252
- const files = readdirSync(projectDir);
253
- const jsonlFiles = files
254
- .filter(f => f.endsWith('.jsonl'))
255
- .map(f => ({
256
- name: f,
257
- path: join(projectDir, f),
258
- mtime: statSync(join(projectDir, f)).mtime.getTime()
259
- }))
260
- .sort((a, b) => b.mtime - a.mtime); // newest first
261
-
262
- if (jsonlFiles.length > 1) {
263
- const { mkdirSync, renameSync } = await import('fs');
264
- const sessionsDir = join(projectDir, 'sessions');
265
- if (!existsSync(sessionsDir)) {
266
- mkdirSync(sessionsDir, { recursive: true });
267
- }
268
-
269
- // Move all except the newest
270
- for (let i = 1; i < jsonlFiles.length; i++) {
271
- const file = jsonlFiles[i];
272
- const destPath = join(sessionsDir, file.name);
273
- if (!existsSync(destPath)) {
274
- renameSync(file.path, destPath);
275
- console.error(`Moved old session: ${file.name} → sessions/`);
276
- }
277
- }
278
- }
278
+ // Exclude this session's own transcript: it is being written right now,
279
+ // and the archive is meant to hold finished sessions.
280
+ const own = hookInput?.session_id ? `${hookInput.session_id}.jsonl` : undefined;
281
+ archiveSessionFilesToSessionsDir(projectDir, own, true);
279
282
  } catch (error) {
280
- console.error(`Could not cleanup old .jsonl files: ${error}`);
283
+ console.error(`Could not archive session transcripts: ${error}`);
281
284
  }
282
285
  }
283
286
 
@@ -8,7 +8,7 @@ import {
8
8
  sendNtfyNotification,
9
9
  getCurrentNotePath,
10
10
  finalizeSessionNote,
11
- moveSessionFilesToSessionsDir,
11
+ archiveSessionFilesToSessionsDir,
12
12
  addWorkToSessionNote,
13
13
  findNotesDir,
14
14
  isProbeSession,
@@ -668,12 +668,12 @@ async function executeDirectly(
668
668
  // Move session .jsonl files to sessions/
669
669
  try {
670
670
  const transcriptDir = dirname(transcriptPath);
671
- const movedCount = moveSessionFilesToSessionsDir(transcriptDir);
672
- if (movedCount > 0) {
673
- console.error(`Moved ${movedCount} session file(s) to sessions/`);
671
+ const archivedCount = archiveSessionFilesToSessionsDir(transcriptDir);
672
+ if (archivedCount > 0) {
673
+ console.error(`Archived ${archivedCount} session file(s) to sessions/`);
674
674
  }
675
675
  } catch (moveError) {
676
- console.error(`Could not move session files: ${moveError}`);
676
+ console.error(`Could not archive session files: ${moveError}`);
677
677
  }
678
678
  }
679
679
 
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { dirname, basename } from 'path';
12
- import { moveSessionFilesToSessionsDir } from '../lib/project-utils';
12
+ import { archiveSessionFilesToSessionsDir } from '../lib/project-utils';
13
13
 
14
14
  interface HookInput {
15
15
  session_id: string;
@@ -31,11 +31,24 @@ async function main() {
31
31
  const projectDir = dirname(data.transcript_path);
32
32
  const currentSessionFile = basename(data.transcript_path);
33
33
 
34
- // Move stray .jsonl files, excluding the current active session (silent mode)
35
- const movedCount = moveSessionFilesToSessionsDir(projectDir, currentSessionFile, true);
36
-
37
- if (movedCount > 0) {
38
- console.error(`Cleaned up ${movedCount} session file(s) to sessions/`);
34
+ // Archive stray .jsonl files, excluding this session's own transcript.
35
+ //
36
+ // That exclusion is narrow, and worth stating precisely because it is easy to
37
+ // read as more: it keeps a hook from archiving the very file it is watching
38
+ // being written. It does NOT make the archive "finished sessions only" — it
39
+ // excludes exactly one file, the caller's. With two sessions live in one
40
+ // project, each one's prompt still archives the other's in-progress
41
+ // transcript.
42
+ //
43
+ // Harmless now that archiving is a hardlink and nothing is destroyed. If a
44
+ // consumer ever needs "finished only", the guard belongs in that consumer —
45
+ // session-summary-worker could skip a transcript modified seconds ago, or one
46
+ // whose uuid is in AIBroker's live-session list. The archiver cannot know
47
+ // what is live and should not pretend to.
48
+ const archivedCount = archiveSessionFilesToSessionsDir(projectDir, currentSessionFile, true);
49
+
50
+ if (archivedCount > 0) {
51
+ console.error(`Archived ${archivedCount} session file(s) to sessions/`);
39
52
  }
40
53
  } catch {
41
54
  // Silent failure - don't block user prompts
@@ -1 +0,0 @@
1
- {"version":3,"file":"factory-BqAdO21B.mjs","names":[],"sources":["../src/storage/outage.ts","../src/storage/factory.ts"],"sourcesContent":["/**\n * outage.ts — is the storage backend actually reachable right now?\n *\n * The daemon retries a dead backend forever, which is correct: a Postgres\n * container that is down will usually come back, and giving up would lose the\n * work queue. What was wrong is that it did so in complete silence.\n *\n * Observed 2026-07-26: the container was down for roughly two days. The daemon\n * logged \"Postgres unavailable\" 144 times over 36 minutes, the work queue backed\n * up, session notes for the whole period were never written — and\n * `pai daemon status` reported \"Index: idle\" throughout. The one command anyone\n * would run to check said everything was fine.\n *\n * So the outage is recorded where the status command can see it, and escalated\n * once through the notification channels that were already configured and\n * already unused for this.\n */\n\nexport interface BackendOutage {\n backend: string;\n /** When the current run of failures began. */\n since: number;\n /** Consecutive failed attempts so far. */\n attempts: number;\n lastError: string;\n}\n\nlet current: BackendOutage | null = null;\n\n/** Record that the backend is currently unreachable. */\nexport function setBackendOutage(outage: BackendOutage): void {\n current = outage;\n}\n\n/**\n * Record that the backend answered.\n *\n * Called on every successful connection, including the first — so a daemon that\n * never had a problem reports none, and one that recovered stops reporting an\n * outage that has ended.\n */\nexport function clearBackendOutage(): void {\n current = null;\n}\n\n/** The current outage, or null when the backend is answering. */\nexport function getBackendOutage(): BackendOutage | null {\n return current;\n}\n\n/** Human-readable duration, for a status line rather than a log. */\nexport function describeOutage(o: BackendOutage, now = Date.now()): string {\n const mins = Math.max(1, Math.round((now - o.since) / 60_000));\n const forHow = mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;\n return `${o.backend} unreachable for ${forHow}, ${o.attempts} attempts — ${o.lastError}`;\n}\n","/**\n * Storage backend factory.\n *\n * Reads the daemon config and returns the appropriate StorageBackend.\n *\n * When Postgres is the configured backend we NEVER silently fall back to\n * SQLite — doing so would split the corpus across two databases. Instead:\n * - Daemon (waitForPostgres: true) retries Postgres forever with capped\n * backoff until it comes up (handles the boot race where launchd starts\n * the daemon before Docker Desktop / Postgres is ready).\n * - CLI / one-shot callers (default) retry a few times, then throw a clear\n * error rather than returning a wrong/empty SQLite database.\n */\n\nimport type { PaiDaemonConfig } from \"../daemon/config.js\";\nimport type { StorageBackend } from \"./interface.js\";\nimport { setBackendOutage, clearBackendOutage } from \"./outage.js\";\n\nexport interface StorageBackendOptions {\n /**\n * When true, retry Postgres indefinitely instead of giving up. Used by the\n * long-lived daemon so a not-yet-ready Postgres at boot is tolerated.\n * Defaults to false (one-shot CLI behaviour: bounded retries, then throw).\n */\n waitForPostgres?: boolean;\n}\n\n/** Backoff schedule (ms) for the bounded CLI retry path. */\nconst CLI_RETRY_DELAYS_MS = [500, 1_000, 2_000];\n\n/** Backoff cap (ms) for the daemon's infinite retry path. */\nconst DAEMON_RETRY_CAP_MS = 15_000;\n\n/**\n * Create and return the configured StorageBackend.\n *\n * Auto-behaviour:\n * - storageBackend = \"sqlite\" → SQLiteBackend always\n * - storageBackend = \"postgres\" → PostgresBackend (retried; never falls back)\n */\nexport async function createStorageBackend(\n config: PaiDaemonConfig,\n opts: StorageBackendOptions = {}\n): Promise<StorageBackend> {\n if (config.storageBackend === \"postgres\") {\n return await connectPostgres(config, opts.waitForPostgres ?? false);\n }\n\n // Default: SQLite\n return createSQLiteBackend();\n}\n\n/**\n * Attempt a single Postgres connection (ensure DB + test). Returns the live\n * backend on success, or an error string describing why it failed.\n */\nasync function attemptPostgres(\n config: PaiDaemonConfig\n): Promise<{ backend: StorageBackend } | { error: string }> {\n const { PostgresBackend } = await import(\"./postgres.js\");\n const pgConfig = config.postgres ?? {};\n\n let backend: InstanceType<typeof PostgresBackend> | null = null;\n try {\n // Ensure the per-user database exists and has the schema applied.\n await PostgresBackend.ensureDatabase(pgConfig);\n\n backend = new PostgresBackend(pgConfig);\n const err = await backend.testConnection();\n if (err) {\n await backend.close().catch(() => {});\n return { error: err };\n }\n return { backend };\n } catch (e) {\n if (backend) await backend.close().catch(() => {});\n return { error: e instanceof Error ? e.message : String(e) };\n }\n}\n\n/**\n * Consecutive failures before the outage is escalated to the user.\n *\n * Not the first failure: a container restarting, or the daemon starting before\n * Docker is up, recovers within a few seconds and is not worth a notification.\n * By the fifth attempt the backoff has already spent tens of seconds, which is\n * long enough that something is actually wrong.\n */\nconst ESCALATE_AFTER_ATTEMPTS = 5;\n\n/** Tell the user the backend is down, through whatever channels are configured. */\nasync function notifyBackendDown(attempts: number, lastError: string): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n await routeNotification(\n {\n event: \"error\",\n title: \"PAI: storage backend unreachable\",\n message:\n `Postgres has not answered in ${attempts} attempts (${lastError}). ` +\n `Indexing, session notes and the work queue are stalled until it returns. ` +\n `Check the container, then \\`pai daemon status\\`.`,\n },\n loadConfig().notifications\n );\n } catch {\n // A notification that cannot be sent must never take the daemon down with\n // it — the daemon retrying is still the useful behaviour here.\n }\n}\n\n/** And say when it comes back, so the alert is not left hanging. */\nasync function notifyBackendRecovered(\n attempts: number,\n since: number | null\n): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n const mins = since ? Math.max(1, Math.round((Date.now() - since) / 60_000)) : null;\n await routeNotification(\n {\n event: \"completion\",\n title: \"PAI: storage backend back\",\n message:\n `Postgres answered after ${attempts} attempts` +\n (mins ? `, ${mins} min down` : \"\") +\n `. The queue will drain on its own.`,\n },\n loadConfig().notifications\n );\n } catch {\n /* same reasoning as above */\n }\n}\n\nasync function connectPostgres(\n config: PaiDaemonConfig,\n waitForever: boolean\n): Promise<StorageBackend> {\n let attempt = 0;\n let lastError = \"unknown error\";\n let outageSince: number | null = null;\n let escalated = false;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n attempt++;\n const result = await attemptPostgres(config);\n if (\"backend\" in result) {\n if (attempt > 1) {\n process.stderr.write(\n `[pai-daemon] Connected to PostgreSQL backend (after ${attempt} attempts).\\n`\n );\n } else {\n process.stderr.write(\"[pai-daemon] Connected to PostgreSQL backend.\\n\");\n }\n // An outage that ended must stop being reported, or the status command\n // trades one wrong answer for another.\n clearBackendOutage();\n if (escalated) void notifyBackendRecovered(attempt, outageSince);\n return result.backend;\n }\n\n lastError = result.error;\n\n if (!waitForever && attempt > CLI_RETRY_DELAYS_MS.length) {\n // Bounded CLI path exhausted — fail loudly, never silently use SQLite.\n throw new Error(\n `Postgres backend unreachable after ${attempt} attempts: ${lastError}. ` +\n `Is Docker Desktop / Postgres running? Refusing to fall back to SQLite ` +\n `(would split the corpus). Start Postgres and retry.`\n );\n }\n\n const delayMs = waitForever\n ? Math.min(DAEMON_RETRY_CAP_MS, 1_000 * 2 ** Math.min(attempt - 1, 4))\n : CLI_RETRY_DELAYS_MS[attempt - 1];\n\n process.stderr.write(\n `[pai-daemon] Postgres unavailable (${lastError}). ` +\n `Retry ${attempt}${waitForever ? \"\" : `/${CLI_RETRY_DELAYS_MS.length + 1}`} ` +\n `in ${delayMs}ms...\\n`\n );\n\n // Publish the outage so `pai daemon status` can report it. Without this the\n // daemon retries silently forever and status still reads \"idle\" — which is\n // what happened for two days in July: 144 retries over 36 minutes, session\n // notes never written, and the one command anyone would run to check\n // reporting that everything was fine.\n setBackendOutage({\n backend: \"postgres\",\n since: outageSince ?? (outageSince = Date.now()),\n attempts: attempt,\n lastError: String(lastError),\n });\n\n // And escalate once, out loud, rather than only into a log nobody tails.\n // Once — not per retry — because a notification that repeats every few\n // seconds is filtered within a minute and stops being a signal at all.\n if (waitForever && attempt === ESCALATE_AFTER_ATTEMPTS && !escalated) {\n escalated = true;\n void notifyBackendDown(attempt, String(lastError));\n }\n\n await new Promise((r) => setTimeout(r, delayMs));\n }\n}\n\nasync function createSQLiteBackend(): Promise<StorageBackend> {\n const { openFederation } = await import(\"../memory/db.js\");\n const { SQLiteBackend } = await import(\"./sqlite.js\");\n const db = openFederation();\n return new SQLiteBackend(db);\n}\n"],"mappings":"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"}