@tekmidian/pai 0.1.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 (66) hide show
  1. package/ARCHITECTURE.md +567 -0
  2. package/FEATURE.md +108 -0
  3. package/LICENSE +21 -0
  4. package/README.md +101 -0
  5. package/dist/auto-route-D7W6RE06.mjs +86 -0
  6. package/dist/auto-route-D7W6RE06.mjs.map +1 -0
  7. package/dist/cli/index.d.mts +1 -0
  8. package/dist/cli/index.mjs +5927 -0
  9. package/dist/cli/index.mjs.map +1 -0
  10. package/dist/config-DBh1bYM2.mjs +151 -0
  11. package/dist/config-DBh1bYM2.mjs.map +1 -0
  12. package/dist/daemon/index.d.mts +1 -0
  13. package/dist/daemon/index.mjs +56 -0
  14. package/dist/daemon/index.mjs.map +1 -0
  15. package/dist/daemon-mcp/index.d.mts +1 -0
  16. package/dist/daemon-mcp/index.mjs +185 -0
  17. package/dist/daemon-mcp/index.mjs.map +1 -0
  18. package/dist/daemon-v5O897D4.mjs +773 -0
  19. package/dist/daemon-v5O897D4.mjs.map +1 -0
  20. package/dist/db-4lSqLFb8.mjs +199 -0
  21. package/dist/db-4lSqLFb8.mjs.map +1 -0
  22. package/dist/db-BcDxXVBu.mjs +110 -0
  23. package/dist/db-BcDxXVBu.mjs.map +1 -0
  24. package/dist/detect-BHqYcjJ1.mjs +86 -0
  25. package/dist/detect-BHqYcjJ1.mjs.map +1 -0
  26. package/dist/detector-DKA83aTZ.mjs +74 -0
  27. package/dist/detector-DKA83aTZ.mjs.map +1 -0
  28. package/dist/embeddings-mfqv-jFu.mjs +91 -0
  29. package/dist/embeddings-mfqv-jFu.mjs.map +1 -0
  30. package/dist/factory-BDAiKtYR.mjs +42 -0
  31. package/dist/factory-BDAiKtYR.mjs.map +1 -0
  32. package/dist/index.d.mts +307 -0
  33. package/dist/index.d.mts.map +1 -0
  34. package/dist/index.mjs +11 -0
  35. package/dist/indexer-B20bPHL-.mjs +677 -0
  36. package/dist/indexer-B20bPHL-.mjs.map +1 -0
  37. package/dist/indexer-backend-BXaocO5r.mjs +360 -0
  38. package/dist/indexer-backend-BXaocO5r.mjs.map +1 -0
  39. package/dist/ipc-client-DPy7s3iu.mjs +156 -0
  40. package/dist/ipc-client-DPy7s3iu.mjs.map +1 -0
  41. package/dist/mcp/index.d.mts +1 -0
  42. package/dist/mcp/index.mjs +373 -0
  43. package/dist/mcp/index.mjs.map +1 -0
  44. package/dist/migrate-Bwj7qPaE.mjs +241 -0
  45. package/dist/migrate-Bwj7qPaE.mjs.map +1 -0
  46. package/dist/pai-marker-DX_mFLum.mjs +186 -0
  47. package/dist/pai-marker-DX_mFLum.mjs.map +1 -0
  48. package/dist/postgres-Ccvpc6fC.mjs +335 -0
  49. package/dist/postgres-Ccvpc6fC.mjs.map +1 -0
  50. package/dist/rolldown-runtime-95iHPtFO.mjs +18 -0
  51. package/dist/schemas-DjdwzIQ8.mjs +3405 -0
  52. package/dist/schemas-DjdwzIQ8.mjs.map +1 -0
  53. package/dist/search-PjftDxxs.mjs +282 -0
  54. package/dist/search-PjftDxxs.mjs.map +1 -0
  55. package/dist/sqlite-CHUrNtbI.mjs +90 -0
  56. package/dist/sqlite-CHUrNtbI.mjs.map +1 -0
  57. package/dist/tools-CLK4080-.mjs +805 -0
  58. package/dist/tools-CLK4080-.mjs.map +1 -0
  59. package/dist/utils-DEWdIFQ0.mjs +160 -0
  60. package/dist/utils-DEWdIFQ0.mjs.map +1 -0
  61. package/package.json +72 -0
  62. package/templates/README.md +181 -0
  63. package/templates/agent-prefs.example.md +362 -0
  64. package/templates/claude-md.template.md +733 -0
  65. package/templates/pai-project.template.md +13 -0
  66. package/templates/voices.example.json +251 -0
@@ -0,0 +1,241 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ //#region src/registry/migrate.ts
6
+ /**
7
+ * Migration helper: imports the existing JSON session-registry into the
8
+ * new SQLite registry.db.
9
+ *
10
+ * Source file: ~/.claude/session-registry.json
11
+ * Target: openRegistry() → projects + sessions tables
12
+ *
13
+ * The JSON registry uses encoded directory names as keys (Claude Code's
14
+ * encoding: leading `/` is replaced by `-`, then each remaining `/` is also
15
+ * replaced by `-`). This module reverses that encoding to recover the real
16
+ * filesystem path.
17
+ *
18
+ * Session note filenames are expected in one of two formats:
19
+ * Modern: "NNNN - YYYY-MM-DD - Description.md" (space-dash-space)
20
+ * Legacy: "NNNN_YYYY-MM-DD_description.md" (underscores)
21
+ */
22
+ /**
23
+ * Build a lookup table from session-registry.json mapping encoded_dir →
24
+ * original_path. This is the authoritative source for decoding because the
25
+ * encoding is ambiguous: `/`, ` ` (space), `.` (dot), and `-` (literal
26
+ * hyphen) all map to `-` or `--` in ways that cannot be uniquely reversed.
27
+ *
28
+ * Example:
29
+ * `-Users-alice--ssh` encodes `/Users/alice/.ssh`
30
+ * `-Users-alice-dev-projects-04---My-App-My-App-2020---2029`
31
+ * encodes `/Users/alice/dev/projects/04 - My-App/My-App 2020 - 2029`
32
+ *
33
+ * @param jsonPath Path to session-registry.json.
34
+ * Defaults to ~/.claude/session-registry.json.
35
+ * @returns Map from encoded_dir → original_path, or empty map if the file is
36
+ * missing / unparseable.
37
+ */
38
+ function buildEncodedDirMap(jsonPath = join(homedir(), ".claude", "session-registry.json")) {
39
+ const map = /* @__PURE__ */ new Map();
40
+ if (!existsSync(jsonPath)) return map;
41
+ try {
42
+ const raw = readFileSync(jsonPath, "utf8");
43
+ const parsed = JSON.parse(raw);
44
+ if (Array.isArray(parsed.projects)) for (const entry of parsed.projects) {
45
+ const key = entry.encoded_dir;
46
+ const val = entry.original_path;
47
+ if (key && val) map.set(key, val);
48
+ }
49
+ else for (const [key, value] of Object.entries(parsed)) {
50
+ if (key === "version") continue;
51
+ const val = value?.original_path;
52
+ if (val) map.set(key, val);
53
+ }
54
+ } catch {}
55
+ return map;
56
+ }
57
+ /**
58
+ * Reverse Claude Code's directory encoding.
59
+ *
60
+ * Claude Code's actual encoding rules:
61
+ * - `/` (path separator) → `-`
62
+ * - ` ` (space) → `--` (escaped)
63
+ * - `.` (dot) → `--` (escaped)
64
+ * - `-` (literal hyphen) → `--` (escaped)
65
+ *
66
+ * Because space, dot, and hyphen all encode to `--`, the encoding is
67
+ * **lossy** — you cannot unambiguously reverse it. This function therefore
68
+ * provides a *best-effort* heuristic decode (treating `--` as a literal `-`
69
+ * which gives wrong results for paths with spaces or dots).
70
+ *
71
+ * PREFER using {@link buildEncodedDirMap} to get the authoritative mapping
72
+ * from session-registry.json instead of calling this function directly.
73
+ *
74
+ * Examples (best-effort, may be wrong for paths with spaces/dots):
75
+ * `-Users-alice-dev-apps-MyProject` → `/Users/alice/dev/apps/MyProject`
76
+ * `-Users-alice--ssh` → `/Users/alice/-ssh` ← WRONG (actually .ssh)
77
+ *
78
+ * @param encoded The Claude-encoded directory name.
79
+ * @param lookupMap Optional authoritative map from {@link buildEncodedDirMap}.
80
+ * If provided and the key is found, that value is returned
81
+ * instead of the heuristic result.
82
+ */
83
+ function decodeEncodedDir(encoded, lookupMap) {
84
+ if (lookupMap?.has(encoded)) return lookupMap.get(encoded);
85
+ if (encoded.startsWith("-")) return encoded.replace(/-/g, "/");
86
+ return encoded;
87
+ }
88
+ /**
89
+ * Derive a URL-safe kebab-case slug from an arbitrary string.
90
+ *
91
+ * Uses the last path component so that `/Users/alice/dev/my-app` → `my-app`.
92
+ */
93
+ function slugify(value) {
94
+ return (value.includes("/") ? value.replace(/\/$/, "").split("/").pop() ?? value : value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
95
+ }
96
+ /** Match `0027 - 2026-01-04 - Some Description.md` */
97
+ const MODERN_RE = /^(\d{4})\s+-\s+(\d{4}-\d{2}-\d{2})\s+-\s+(.+)\.md$/i;
98
+ /** Match `0027_2026-01-04_some_description.md` */
99
+ const LEGACY_RE = /^(\d{4})_(\d{4}-\d{2}-\d{2})_(.+)\.md$/i;
100
+ /**
101
+ * Attempt to parse a session note filename into its structured parts.
102
+ *
103
+ * Returns `null` if the filename does not match either known format.
104
+ */
105
+ function parseSessionFilename(filename) {
106
+ let m = MODERN_RE.exec(filename);
107
+ if (m) {
108
+ const [, num, date, description] = m;
109
+ return {
110
+ number: parseInt(num, 10),
111
+ date,
112
+ slug: slugify(description),
113
+ title: description.trim(),
114
+ filename
115
+ };
116
+ }
117
+ m = LEGACY_RE.exec(filename);
118
+ if (m) {
119
+ const [, num, date, rawDesc] = m;
120
+ const description = rawDesc.replace(/_/g, " ");
121
+ return {
122
+ number: parseInt(num, 10),
123
+ date,
124
+ slug: slugify(description),
125
+ title: description.trim(),
126
+ filename
127
+ };
128
+ }
129
+ return null;
130
+ }
131
+ /**
132
+ * Migrate the existing JSON session-registry into the SQLite registry.
133
+ *
134
+ * @param db Open better-sqlite3 Database (target).
135
+ * @param registryPath Path to session-registry.json.
136
+ * Defaults to ~/.claude/session-registry.json.
137
+ *
138
+ * The migration is idempotent: projects and sessions that already exist
139
+ * (matched by slug / project_id+number) are silently skipped.
140
+ */
141
+ function migrateFromJson(db, registryPath = join(homedir(), ".claude", "session-registry.json")) {
142
+ const result = {
143
+ projectsInserted: 0,
144
+ projectsSkipped: 0,
145
+ sessionsInserted: 0,
146
+ errors: []
147
+ };
148
+ if (!existsSync(registryPath)) {
149
+ result.errors.push(`Registry file not found: ${registryPath}`);
150
+ return result;
151
+ }
152
+ let registry;
153
+ try {
154
+ const raw = readFileSync(registryPath, "utf8");
155
+ registry = JSON.parse(raw);
156
+ } catch (err) {
157
+ result.errors.push(`Failed to parse registry JSON: ${String(err)}`);
158
+ return result;
159
+ }
160
+ const insertProject = db.prepare(`
161
+ INSERT OR IGNORE INTO projects
162
+ (slug, display_name, root_path, encoded_dir, type, status,
163
+ created_at, updated_at)
164
+ VALUES
165
+ (@slug, @display_name, @root_path, @encoded_dir, 'local', 'active',
166
+ @created_at, @updated_at)
167
+ `);
168
+ const getProject = db.prepare("SELECT id FROM projects WHERE slug = ?");
169
+ const insertSession = db.prepare(`
170
+ INSERT OR IGNORE INTO sessions
171
+ (project_id, number, date, slug, title, filename, status, created_at)
172
+ VALUES
173
+ (@project_id, @number, @date, @slug, @title, @filename, 'completed',
174
+ @created_at)
175
+ `);
176
+ const now = Date.now();
177
+ const lookupMap = buildEncodedDirMap(registryPath);
178
+ for (const [encodedDir, entry] of Object.entries(registry)) {
179
+ const rootPath = decodeEncodedDir(encodedDir, lookupMap);
180
+ const baseSlug = slugify(rootPath);
181
+ let slug = baseSlug;
182
+ let attempt = 0;
183
+ while (true) {
184
+ if (insertProject.run({
185
+ slug,
186
+ display_name: entry.displayName ?? rootPath.split("/").pop() ?? rootPath,
187
+ root_path: rootPath,
188
+ encoded_dir: encodedDir,
189
+ created_at: now,
190
+ updated_at: now
191
+ }).changes > 0) {
192
+ result.projectsInserted++;
193
+ break;
194
+ }
195
+ if (db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath)) {
196
+ result.projectsSkipped++;
197
+ break;
198
+ }
199
+ attempt++;
200
+ slug = `${baseSlug}-${attempt}`;
201
+ }
202
+ const projectById = getProject.get(slug) ?? db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
203
+ if (!projectById) {
204
+ result.errors.push(`Could not resolve project id for encoded dir: ${encodedDir}`);
205
+ continue;
206
+ }
207
+ const projectId = projectById.id;
208
+ const notesDir = typeof entry.notesDir === "string" ? entry.notesDir : join(rootPath, "Notes");
209
+ if (!existsSync(notesDir)) continue;
210
+ let files;
211
+ try {
212
+ files = readdirSync(notesDir);
213
+ } catch (err) {
214
+ result.errors.push(`Cannot read notes dir ${notesDir}: ${String(err)}`);
215
+ continue;
216
+ }
217
+ for (const filename of files) {
218
+ if (!filename.endsWith(".md")) continue;
219
+ const parsed = parseSessionFilename(filename);
220
+ if (!parsed) continue;
221
+ try {
222
+ if (insertSession.run({
223
+ project_id: projectId,
224
+ number: parsed.number,
225
+ date: parsed.date,
226
+ slug: parsed.slug,
227
+ title: parsed.title,
228
+ filename: parsed.filename,
229
+ created_at: now
230
+ }).changes > 0) result.sessionsInserted++;
231
+ } catch (err) {
232
+ result.errors.push(`Failed to insert session ${filename}: ${String(err)}`);
233
+ }
234
+ }
235
+ }
236
+ return result;
237
+ }
238
+
239
+ //#endregion
240
+ export { slugify as a, parseSessionFilename as i, decodeEncodedDir as n, migrateFromJson as r, buildEncodedDirMap as t };
241
+ //# sourceMappingURL=migrate-Bwj7qPaE.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrate-Bwj7qPaE.mjs","names":[],"sources":["../src/registry/migrate.ts"],"sourcesContent":["/**\n * Migration helper: imports the existing JSON session-registry into the\n * new SQLite registry.db.\n *\n * Source file: ~/.claude/session-registry.json\n * Target: openRegistry() → projects + sessions tables\n *\n * The JSON registry uses encoded directory names as keys (Claude Code's\n * encoding: leading `/` is replaced by `-`, then each remaining `/` is also\n * replaced by `-`). This module reverses that encoding to recover the real\n * filesystem path.\n *\n * Session note filenames are expected in one of two formats:\n * Modern: \"NNNN - YYYY-MM-DD - Description.md\" (space-dash-space)\n * Legacy: \"NNNN_YYYY-MM-DD_description.md\" (underscores)\n */\n\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { Database } from \"better-sqlite3\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Shape of a single entry in session-registry.json */\ninterface RegistryEntry {\n /** Absolute path to the Notes/ directory for this project */\n notesDir?: string;\n /** Display name stored in the registry (optional) */\n displayName?: string;\n /** Any other keys the file might carry */\n [key: string]: unknown;\n}\n\n/** Top-level shape of session-registry.json */\ntype SessionRegistry = Record<string, RegistryEntry>;\n\n// ---------------------------------------------------------------------------\n// Encoding / decoding\n// ---------------------------------------------------------------------------\n\n/**\n * Build a lookup table from session-registry.json mapping encoded_dir →\n * original_path. This is the authoritative source for decoding because the\n * encoding is ambiguous: `/`, ` ` (space), `.` (dot), and `-` (literal\n * hyphen) all map to `-` or `--` in ways that cannot be uniquely reversed.\n *\n * Example:\n * `-Users-alice--ssh` encodes `/Users/alice/.ssh`\n * `-Users-alice-dev-projects-04---My-App-My-App-2020---2029`\n * encodes `/Users/alice/dev/projects/04 - My-App/My-App 2020 - 2029`\n *\n * @param jsonPath Path to session-registry.json.\n * Defaults to ~/.claude/session-registry.json.\n * @returns Map from encoded_dir → original_path, or empty map if the file is\n * missing / unparseable.\n */\nexport function buildEncodedDirMap(\n jsonPath: string = join(homedir(), \".claude\", \"session-registry.json\")\n): Map<string, string> {\n const map = new Map<string, string>();\n if (!existsSync(jsonPath)) return map;\n\n try {\n const raw = readFileSync(jsonPath, \"utf8\");\n const parsed = JSON.parse(raw) as Record<string, unknown>;\n\n // Support both formats:\n // list-based: { \"projects\": [ { \"encoded_dir\", \"original_path\" }, ... ] }\n // object-keyed: { \"<encoded_dir>\": { ... } } (original Claude format)\n if (Array.isArray(parsed.projects)) {\n for (const entry of parsed.projects as Array<Record<string, unknown>>) {\n const key = entry.encoded_dir as string | undefined;\n const val = entry.original_path as string | undefined;\n if (key && val) map.set(key, val);\n }\n } else {\n // Object-keyed format — keys are encoded dirs\n for (const [key, value] of Object.entries(parsed)) {\n if (key === \"version\") continue;\n const val = (value as Record<string, unknown>)?.original_path as\n | string\n | undefined;\n if (val) map.set(key, val);\n }\n }\n } catch {\n // Unparseable — return empty map; callers fall back to heuristic decode\n }\n\n return map;\n}\n\n/**\n * Reverse Claude Code's directory encoding.\n *\n * Claude Code's actual encoding rules:\n * - `/` (path separator) → `-`\n * - ` ` (space) → `--` (escaped)\n * - `.` (dot) → `--` (escaped)\n * - `-` (literal hyphen) → `--` (escaped)\n *\n * Because space, dot, and hyphen all encode to `--`, the encoding is\n * **lossy** — you cannot unambiguously reverse it. This function therefore\n * provides a *best-effort* heuristic decode (treating `--` as a literal `-`\n * which gives wrong results for paths with spaces or dots).\n *\n * PREFER using {@link buildEncodedDirMap} to get the authoritative mapping\n * from session-registry.json instead of calling this function directly.\n *\n * Examples (best-effort, may be wrong for paths with spaces/dots):\n * `-Users-alice-dev-apps-MyProject` → `/Users/alice/dev/apps/MyProject`\n * `-Users-alice--ssh` → `/Users/alice/-ssh` ← WRONG (actually .ssh)\n *\n * @param encoded The Claude-encoded directory name.\n * @param lookupMap Optional authoritative map from {@link buildEncodedDirMap}.\n * If provided and the key is found, that value is returned\n * instead of the heuristic result.\n */\nexport function decodeEncodedDir(\n encoded: string,\n lookupMap?: Map<string, string>\n): string {\n // Authoritative lookup wins\n if (lookupMap?.has(encoded)) {\n return lookupMap.get(encoded)!;\n }\n\n // Best-effort heuristic: every `-` maps to `/`.\n // This is correct for simple paths (no spaces, dots, or literal hyphens\n // in component names) but will produce wrong results for e.g. `.ssh`\n // (decoded as `/ssh` instead of `/.ssh`). That's acceptable here because\n // callers should be using the lookupMap for paths that exist in the registry.\n if (encoded.startsWith(\"-\")) {\n return encoded.replace(/-/g, \"/\");\n }\n\n // Not a Claude-encoded path — return as-is\n return encoded;\n}\n\n// ---------------------------------------------------------------------------\n// Slug generation\n// ---------------------------------------------------------------------------\n\n/**\n * Derive a URL-safe kebab-case slug from an arbitrary string.\n *\n * Uses the last path component so that `/Users/alice/dev/my-app` → `my-app`.\n */\nexport function slugify(value: string): string {\n // Take last path segment if it looks like a path\n const segment = value.includes(\"/\")\n ? value.replace(/\\/$/, \"\").split(\"/\").pop() ?? value\n : value;\n\n return segment\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\") // non-alphanumeric runs → single dash\n .replace(/^-+|-+$/g, \"\"); // trim leading/trailing dashes\n}\n\n// ---------------------------------------------------------------------------\n// Session note parsing\n// ---------------------------------------------------------------------------\n\ninterface ParsedSession {\n number: number;\n date: string;\n slug: string;\n title: string;\n filename: string;\n}\n\n/** Match `0027 - 2026-01-04 - Some Description.md` */\nconst MODERN_RE = /^(\\d{4})\\s+-\\s+(\\d{4}-\\d{2}-\\d{2})\\s+-\\s+(.+)\\.md$/i;\n\n/** Match `0027_2026-01-04_some_description.md` */\nconst LEGACY_RE = /^(\\d{4})_(\\d{4}-\\d{2}-\\d{2})_(.+)\\.md$/i;\n\n/**\n * Attempt to parse a session note filename into its structured parts.\n *\n * Returns `null` if the filename does not match either known format.\n */\nexport function parseSessionFilename(\n filename: string\n): ParsedSession | null {\n let m = MODERN_RE.exec(filename);\n if (m) {\n const [, num, date, description] = m;\n return {\n number: parseInt(num, 10),\n date,\n slug: slugify(description),\n title: description.trim(),\n filename,\n };\n }\n\n m = LEGACY_RE.exec(filename);\n if (m) {\n const [, num, date, rawDesc] = m;\n const description = rawDesc.replace(/_/g, \" \");\n return {\n number: parseInt(num, 10),\n date,\n slug: slugify(description),\n title: description.trim(),\n filename,\n };\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Migration\n// ---------------------------------------------------------------------------\n\nexport interface MigrationResult {\n projectsInserted: number;\n projectsSkipped: number;\n sessionsInserted: number;\n errors: string[];\n}\n\n/**\n * Migrate the existing JSON session-registry into the SQLite registry.\n *\n * @param db Open better-sqlite3 Database (target).\n * @param registryPath Path to session-registry.json.\n * Defaults to ~/.claude/session-registry.json.\n *\n * The migration is idempotent: projects and sessions that already exist\n * (matched by slug / project_id+number) are silently skipped.\n */\nexport function migrateFromJson(\n db: Database,\n registryPath: string = join(homedir(), \".claude\", \"session-registry.json\")\n): MigrationResult {\n const result: MigrationResult = {\n projectsInserted: 0,\n projectsSkipped: 0,\n sessionsInserted: 0,\n errors: [],\n };\n\n // ── Load source file ──────────────────────────────────────────────────────\n if (!existsSync(registryPath)) {\n result.errors.push(`Registry file not found: ${registryPath}`);\n return result;\n }\n\n let registry: SessionRegistry;\n try {\n const raw = readFileSync(registryPath, \"utf8\");\n registry = JSON.parse(raw) as SessionRegistry;\n } catch (err) {\n result.errors.push(`Failed to parse registry JSON: ${String(err)}`);\n return result;\n }\n\n // ── Prepared statements ───────────────────────────────────────────────────\n const insertProject = db.prepare(`\n INSERT OR IGNORE INTO projects\n (slug, display_name, root_path, encoded_dir, type, status,\n created_at, updated_at)\n VALUES\n (@slug, @display_name, @root_path, @encoded_dir, 'local', 'active',\n @created_at, @updated_at)\n `);\n\n const getProject = db.prepare(\n \"SELECT id FROM projects WHERE slug = ?\"\n );\n\n const insertSession = db.prepare(`\n INSERT OR IGNORE INTO sessions\n (project_id, number, date, slug, title, filename, status, created_at)\n VALUES\n (@project_id, @number, @date, @slug, @title, @filename, 'completed',\n @created_at)\n `);\n\n const now = Date.now();\n\n // ── Build authoritative encoded-dir → path lookup ─────────────────────────\n const lookupMap = buildEncodedDirMap(registryPath);\n\n // ── Process each encoded directory entry ──────────────────────────────────\n for (const [encodedDir, entry] of Object.entries(registry)) {\n const rootPath = decodeEncodedDir(encodedDir, lookupMap);\n const baseSlug = slugify(rootPath);\n\n // --- Upsert project ---\n let slug = baseSlug;\n let attempt = 0;\n while (true) {\n const info = insertProject.run({\n slug,\n display_name:\n (entry.displayName as string | undefined) ??\n (rootPath.split(\"/\").pop() ?? rootPath),\n root_path: rootPath,\n encoded_dir: encodedDir,\n created_at: now,\n updated_at: now,\n });\n\n if (info.changes > 0) {\n result.projectsInserted++;\n break;\n }\n\n // Row existed — check if it's ours (matching root_path) or a collision\n const existing = db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(rootPath);\n if (existing) {\n result.projectsSkipped++;\n break;\n }\n\n // Genuine slug collision — append numeric suffix and retry\n attempt++;\n slug = `${baseSlug}-${attempt}`;\n }\n\n const projectRow = getProject.get(slug) as { id: number } | undefined;\n // Also check by root_path in case slug was different\n const projectById = projectRow ??\n (db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(rootPath) as { id: number } | undefined);\n\n if (!projectById) {\n result.errors.push(\n `Could not resolve project id for encoded dir: ${encodedDir}`\n );\n continue;\n }\n\n const projectId = projectById.id;\n\n // --- Scan Notes/ directory for session notes ---\n const notesDir =\n typeof entry.notesDir === \"string\"\n ? entry.notesDir\n : join(rootPath, \"Notes\");\n\n if (!existsSync(notesDir)) {\n // No notes directory — that is fine, project still gets created\n continue;\n }\n\n let files: string[];\n try {\n files = readdirSync(notesDir);\n } catch (err) {\n result.errors.push(\n `Cannot read notes dir ${notesDir}: ${String(err)}`\n );\n continue;\n }\n\n for (const filename of files) {\n if (!filename.endsWith(\".md\")) continue;\n\n const parsed = parseSessionFilename(filename);\n if (!parsed) continue;\n\n try {\n const info = insertSession.run({\n project_id: projectId,\n number: parsed.number,\n date: parsed.date,\n slug: parsed.slug,\n title: parsed.title,\n filename: parsed.filename,\n created_at: now,\n });\n if (info.changes > 0) result.sessionsInserted++;\n } catch (err) {\n result.errors.push(\n `Failed to insert session ${filename}: ${String(err)}`\n );\n }\n }\n }\n\n return result;\n}\n"],"mappings":"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"}
@@ -0,0 +1,186 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ //#region src/registry/pai-marker.ts
5
+ /**
6
+ * PAI.md marker file management.
7
+ *
8
+ * Each registered project gets a `Notes/PAI.md` file with a YAML frontmatter
9
+ * `pai:` block that PAI manages. The rest of the file (body content, other
10
+ * frontmatter keys) is user-owned and never modified by PAI.
11
+ *
12
+ * YAML parsing/updating is done with simple regex — no external dependency.
13
+ */
14
+ const TEMPLATE = `---
15
+ pai:
16
+ slug: "\${SLUG}"
17
+ registered: "\${DATE}"
18
+ last_indexed: null
19
+ status: active
20
+ ---
21
+
22
+ # \${DISPLAY_NAME}
23
+
24
+ <!-- Everything below the YAML frontmatter is yours — PAI never modifies content here. -->
25
+ <!-- Use this file for project notes, decisions, preferences, or anything you want. -->
26
+ <!-- PAI only reads and updates the \`pai:\` block in the frontmatter above. -->
27
+ `;
28
+ function isoDate() {
29
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
30
+ }
31
+ function renderTemplate(slug, displayName) {
32
+ return TEMPLATE.replace(/\$\{SLUG\}/g, slug).replace(/\$\{DATE\}/g, isoDate()).replace(/\$\{DISPLAY_NAME\}/g, displayName);
33
+ }
34
+ /**
35
+ * Split a markdown file with YAML frontmatter into its parts.
36
+ *
37
+ * Returns { frontmatter, body } where:
38
+ * frontmatter — content between the opening and closing `---` delimiters
39
+ * body — everything after the closing `---` line
40
+ *
41
+ * Returns null if the file does not begin with a `---` frontmatter block.
42
+ */
43
+ function parseFrontmatter(content) {
44
+ if (!content.startsWith("---")) return null;
45
+ const afterOpen = content.slice(3);
46
+ const eolMatch = afterOpen.match(/^\r?\n/);
47
+ if (!eolMatch) return null;
48
+ const rest = afterOpen.slice(eolMatch[0].length);
49
+ const closeMatch = rest.match(/^([\s\S]*?)\n---[ \t]*(\r?\n|$)/m);
50
+ if (!closeMatch) return null;
51
+ return {
52
+ frontmatter: closeMatch[1],
53
+ body: rest.slice(closeMatch[0].length)
54
+ };
55
+ }
56
+ /**
57
+ * Extract a simple scalar YAML value from a block of YAML text.
58
+ *
59
+ * extractYamlValue(' slug: "my-proj"', "slug") → "my-proj"
60
+ * extractYamlValue(' slug: my-proj', "slug") → "my-proj"
61
+ * extractYamlValue(' last_indexed: null', "last_indexed") → "null"
62
+ */
63
+ function extractYamlValue(yamlBlock, key) {
64
+ const re = new RegExp(`^[ \\t]*${key}[ \\t]*:[ \\t]*"?([^"\\n\\r]*?)"?[ \\t]*$`, "m");
65
+ const match = yamlBlock.match(re);
66
+ if (!match) return null;
67
+ return match[1].trim() || null;
68
+ }
69
+ /**
70
+ * Replace the `pai:` mapping block inside a frontmatter string with
71
+ * `newPaiBlock`. If no `pai:` block is found, appends it at the end.
72
+ *
73
+ * The regex captures the `pai:` key and all immediately-following indented
74
+ * lines (the mapping values), then replaces the whole group.
75
+ *
76
+ * Edge case: the last indented line may not have a trailing newline when it
77
+ * is the final line of the frontmatter string. We handle this by matching
78
+ * lines that end with \n OR with end-of-string.
79
+ */
80
+ function replacePaiBlock(frontmatter, newPaiBlock) {
81
+ const fm = frontmatter.endsWith("\n") ? frontmatter : frontmatter + "\n";
82
+ const paiRe = /^pai:[ \t]*\r?\n(?:[ \t]+[^\r\n]*\r?\n)*/m;
83
+ if (paiRe.test(fm)) {
84
+ const replaced = fm.replace(paiRe, newPaiBlock);
85
+ return frontmatter.endsWith("\n") ? replaced : replaced.replace(/\n$/, "");
86
+ }
87
+ return fm + newPaiBlock;
88
+ }
89
+ /**
90
+ * Build the canonical `pai:` YAML block (with a trailing newline).
91
+ */
92
+ function buildPaiBlock(slug, registered, lastIndexed, status) {
93
+ return `pai:\n slug: "${slug}"\n registered: "${registered}"\n last_indexed: ${lastIndexed === null ? "null" : `"${lastIndexed}"`}\n status: ${status}\n`;
94
+ }
95
+ /**
96
+ * Create or update `<projectRoot>/Notes/PAI.md`.
97
+ *
98
+ * - File absent: creates `Notes/` if needed, writes from template.
99
+ * - File present: updates only the `pai:` frontmatter block; body and all
100
+ * other frontmatter keys are preserved verbatim.
101
+ *
102
+ * @param projectRoot Absolute path to the project root directory.
103
+ * @param slug PAI slug for this project.
104
+ * @param displayName Human-readable name (defaults to slug if omitted).
105
+ */
106
+ function ensurePaiMarker(projectRoot, slug, displayName) {
107
+ const notesDir = join(projectRoot, "Notes");
108
+ const markerPath = join(notesDir, "PAI.md");
109
+ const name = displayName ?? slug;
110
+ if (!existsSync(markerPath)) {
111
+ mkdirSync(notesDir, { recursive: true });
112
+ writeFileSync(markerPath, renderTemplate(slug, name), "utf8");
113
+ return;
114
+ }
115
+ const raw = readFileSync(markerPath, "utf8");
116
+ const parsed = parseFrontmatter(raw);
117
+ if (!parsed) {
118
+ writeFileSync(markerPath, `---\n${buildPaiBlock(slug, isoDate(), null, "active")}---\n\n${raw}`, "utf8");
119
+ return;
120
+ }
121
+ const { frontmatter, body } = parsed;
122
+ const existingRegistered = extractYamlValue(frontmatter, "registered") ?? isoDate();
123
+ const rawLastIndexed = extractYamlValue(frontmatter, "last_indexed");
124
+ const newFrontmatter = replacePaiBlock(frontmatter, buildPaiBlock(slug, existingRegistered, rawLastIndexed === null || rawLastIndexed === "null" ? null : rawLastIndexed, extractYamlValue(frontmatter, "status") ?? "active"));
125
+ writeFileSync(markerPath, `---\n${newFrontmatter.endsWith("\n") ? newFrontmatter : newFrontmatter + "\n"}---\n${body}`, "utf8");
126
+ }
127
+ /**
128
+ * Read PAI marker data from `<projectRoot>/Notes/PAI.md`.
129
+ * Returns null if the file does not exist or contains no `pai:` block.
130
+ */
131
+ function readPaiMarker(projectRoot) {
132
+ const markerPath = join(projectRoot, "Notes", "PAI.md");
133
+ if (!existsSync(markerPath)) return null;
134
+ const parsed = parseFrontmatter(readFileSync(markerPath, "utf8"));
135
+ if (!parsed) return null;
136
+ const slug = extractYamlValue(parsed.frontmatter, "slug");
137
+ if (!slug) return null;
138
+ return {
139
+ slug,
140
+ registered: extractYamlValue(parsed.frontmatter, "registered") ?? "",
141
+ status: extractYamlValue(parsed.frontmatter, "status") ?? "active"
142
+ };
143
+ }
144
+ /**
145
+ * Scan a list of parent directories for `<child>/Notes/PAI.md` marker files.
146
+ * Each directory in `searchDirs` is scanned one level deep — its immediate
147
+ * child directories are checked for a `Notes/PAI.md` file.
148
+ *
149
+ * Returns an array of PaiMarker objects for every valid marker found.
150
+ * Invalid or malformed markers are silently skipped.
151
+ *
152
+ * @param searchDirs Absolute paths to parent directories.
153
+ */
154
+ function discoverPaiMarkers(searchDirs) {
155
+ const results = [];
156
+ for (const dir of searchDirs) {
157
+ if (!existsSync(dir)) continue;
158
+ let children;
159
+ try {
160
+ children = readdirSync(dir);
161
+ } catch {
162
+ continue;
163
+ }
164
+ for (const child of children) {
165
+ if (child.startsWith(".")) continue;
166
+ const childPath = join(dir, child);
167
+ try {
168
+ if (!statSync(childPath).isDirectory()) continue;
169
+ } catch {
170
+ continue;
171
+ }
172
+ const markerData = readPaiMarker(childPath);
173
+ if (!markerData) continue;
174
+ results.push({
175
+ path: join(childPath, "Notes", "PAI.md"),
176
+ slug: markerData.slug,
177
+ projectRoot: childPath
178
+ });
179
+ }
180
+ }
181
+ return results;
182
+ }
183
+
184
+ //#endregion
185
+ export { ensurePaiMarker as n, readPaiMarker as r, discoverPaiMarkers as t };
186
+ //# sourceMappingURL=pai-marker-DX_mFLum.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pai-marker-DX_mFLum.mjs","names":[],"sources":["../src/registry/pai-marker.ts"],"sourcesContent":["/**\n * PAI.md marker file management.\n *\n * Each registered project gets a `Notes/PAI.md` file with a YAML frontmatter\n * `pai:` block that PAI manages. The rest of the file (body content, other\n * frontmatter keys) is user-owned and never modified by PAI.\n *\n * YAML parsing/updating is done with simple regex — no external dependency.\n */\n\nimport {\n existsSync,\n readFileSync,\n writeFileSync,\n mkdirSync,\n readdirSync,\n statSync,\n} from \"node:fs\";\nimport { join } from \"node:path\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PaiMarker {\n /** Absolute path to the PAI.md file */\n path: string;\n /** The `slug` value from the `pai:` frontmatter block */\n slug: string;\n /** Absolute path to the project root (parent of Notes/) */\n projectRoot: string;\n}\n\n// ---------------------------------------------------------------------------\n// Template content (mirrors templates/pai-project.template.md)\n// ---------------------------------------------------------------------------\n\nconst TEMPLATE = `---\npai:\n slug: \"\\${SLUG}\"\n registered: \"\\${DATE}\"\n last_indexed: null\n status: active\n---\n\n# \\${DISPLAY_NAME}\n\n<!-- Everything below the YAML frontmatter is yours — PAI never modifies content here. -->\n<!-- Use this file for project notes, decisions, preferences, or anything you want. -->\n<!-- PAI only reads and updates the \\`pai:\\` block in the frontmatter above. -->\n`;\n\nfunction isoDate(): string {\n return new Date().toISOString().slice(0, 10);\n}\n\nfunction renderTemplate(slug: string, displayName: string): string {\n return TEMPLATE.replace(/\\$\\{SLUG\\}/g, slug)\n .replace(/\\$\\{DATE\\}/g, isoDate())\n .replace(/\\$\\{DISPLAY_NAME\\}/g, displayName);\n}\n\n// ---------------------------------------------------------------------------\n// YAML frontmatter helpers (regex-based, no external dependency)\n// ---------------------------------------------------------------------------\n\n/**\n * Split a markdown file with YAML frontmatter into its parts.\n *\n * Returns { frontmatter, body } where:\n * frontmatter — content between the opening and closing `---` delimiters\n * body — everything after the closing `---` line\n *\n * Returns null if the file does not begin with a `---` frontmatter block.\n */\nfunction parseFrontmatter(\n content: string\n): { frontmatter: string; body: string } | null {\n if (!content.startsWith(\"---\")) return null;\n\n // Skip past the opening \"---\" and its line ending\n const afterOpen = content.slice(3);\n const eolMatch = afterOpen.match(/^\\r?\\n/);\n if (!eolMatch) return null;\n\n const rest = afterOpen.slice(eolMatch[0].length);\n\n // Find closing \"---\" on its own line\n const closeMatch = rest.match(/^([\\s\\S]*?)\\n---[ \\t]*(\\r?\\n|$)/m);\n if (!closeMatch) return null;\n\n const frontmatter = closeMatch[1];\n const body = rest.slice(closeMatch[0].length);\n\n return { frontmatter, body };\n}\n\n/**\n * Extract a simple scalar YAML value from a block of YAML text.\n *\n * extractYamlValue(' slug: \"my-proj\"', \"slug\") → \"my-proj\"\n * extractYamlValue(' slug: my-proj', \"slug\") → \"my-proj\"\n * extractYamlValue(' last_indexed: null', \"last_indexed\") → \"null\"\n */\nfunction extractYamlValue(yamlBlock: string, key: string): string | null {\n const re = new RegExp(\n `^[ \\\\t]*${key}[ \\\\t]*:[ \\\\t]*\"?([^\"\\\\n\\\\r]*?)\"?[ \\\\t]*$`,\n \"m\"\n );\n const match = yamlBlock.match(re);\n if (!match) return null;\n return match[1].trim() || null;\n}\n\n/**\n * Replace the `pai:` mapping block inside a frontmatter string with\n * `newPaiBlock`. If no `pai:` block is found, appends it at the end.\n *\n * The regex captures the `pai:` key and all immediately-following indented\n * lines (the mapping values), then replaces the whole group.\n *\n * Edge case: the last indented line may not have a trailing newline when it\n * is the final line of the frontmatter string. We handle this by matching\n * lines that end with \\n OR with end-of-string.\n */\nfunction replacePaiBlock(frontmatter: string, newPaiBlock: string): string {\n // Normalise: ensure the frontmatter string ends with \\n so the regex\n // always finds a clean boundary after the last indented line.\n const fm = frontmatter.endsWith(\"\\n\") ? frontmatter : frontmatter + \"\\n\";\n\n // Match \"pai:\\n\" followed by any number of indented lines (each ending \\n).\n const paiRe = /^pai:[ \\t]*\\r?\\n(?:[ \\t]+[^\\r\\n]*\\r?\\n)*/m;\n if (paiRe.test(fm)) {\n // Replace and strip the extra trailing \\n we may have added.\n const replaced = fm.replace(paiRe, newPaiBlock);\n return frontmatter.endsWith(\"\\n\") ? replaced : replaced.replace(/\\n$/, \"\");\n }\n // pai: key not found — append it\n return fm + newPaiBlock;\n}\n\n/**\n * Build the canonical `pai:` YAML block (with a trailing newline).\n */\nfunction buildPaiBlock(\n slug: string,\n registered: string,\n lastIndexed: string | null,\n status: string\n): string {\n const lastIndexedStr =\n lastIndexed === null ? \"null\" : `\"${lastIndexed}\"`;\n return (\n `pai:\\n` +\n ` slug: \"${slug}\"\\n` +\n ` registered: \"${registered}\"\\n` +\n ` last_indexed: ${lastIndexedStr}\\n` +\n ` status: ${status}\\n`\n );\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Create or update `<projectRoot>/Notes/PAI.md`.\n *\n * - File absent: creates `Notes/` if needed, writes from template.\n * - File present: updates only the `pai:` frontmatter block; body and all\n * other frontmatter keys are preserved verbatim.\n *\n * @param projectRoot Absolute path to the project root directory.\n * @param slug PAI slug for this project.\n * @param displayName Human-readable name (defaults to slug if omitted).\n */\nexport function ensurePaiMarker(\n projectRoot: string,\n slug: string,\n displayName?: string\n): void {\n const notesDir = join(projectRoot, \"Notes\");\n const markerPath = join(notesDir, \"PAI.md\");\n const name = displayName ?? slug;\n\n // --- File does not exist — create from template ---\n if (!existsSync(markerPath)) {\n mkdirSync(notesDir, { recursive: true });\n writeFileSync(markerPath, renderTemplate(slug, name), \"utf8\");\n return;\n }\n\n // --- File exists — update only the `pai:` block ---\n const raw = readFileSync(markerPath, \"utf8\");\n const parsed = parseFrontmatter(raw);\n\n if (!parsed) {\n // No YAML frontmatter — prepend a fresh one, treat the whole file as body.\n const paiBlock = buildPaiBlock(slug, isoDate(), null, \"active\");\n const newContent = `---\\n${paiBlock}---\\n\\n${raw}`;\n writeFileSync(markerPath, newContent, \"utf8\");\n return;\n }\n\n const { frontmatter, body } = parsed;\n\n // Preserve existing `registered` date so we don't reset it on re-scan.\n const existingRegistered =\n extractYamlValue(frontmatter, \"registered\") ?? isoDate();\n\n // Preserve existing `last_indexed` value (may be \"null\" string or a date).\n const rawLastIndexed = extractYamlValue(frontmatter, \"last_indexed\");\n const lastIndexed =\n rawLastIndexed === null || rawLastIndexed === \"null\"\n ? null\n : rawLastIndexed;\n\n // Preserve existing `status`.\n const existingStatus = extractYamlValue(frontmatter, \"status\") ?? \"active\";\n\n const newPaiBlock = buildPaiBlock(\n slug,\n existingRegistered,\n lastIndexed,\n existingStatus\n );\n\n const newFrontmatter = replacePaiBlock(frontmatter, newPaiBlock);\n\n // Ensure the frontmatter block ends with exactly one newline before the\n // closing --- delimiter.\n const fmWithNewline = newFrontmatter.endsWith(\"\\n\")\n ? newFrontmatter\n : newFrontmatter + \"\\n\";\n\n // Reconstruct the full file. Preserve whatever separator the body has.\n const newContent = `---\\n${fmWithNewline}---\\n${body}`;\n writeFileSync(markerPath, newContent, \"utf8\");\n}\n\n/**\n * Read PAI marker data from `<projectRoot>/Notes/PAI.md`.\n * Returns null if the file does not exist or contains no `pai:` block.\n */\nexport function readPaiMarker(\n projectRoot: string\n): { slug: string; registered: string; status: string } | null {\n const markerPath = join(projectRoot, \"Notes\", \"PAI.md\");\n if (!existsSync(markerPath)) return null;\n\n const raw = readFileSync(markerPath, \"utf8\");\n const parsed = parseFrontmatter(raw);\n if (!parsed) return null;\n\n const slug = extractYamlValue(parsed.frontmatter, \"slug\");\n if (!slug) return null;\n\n const registered =\n extractYamlValue(parsed.frontmatter, \"registered\") ?? \"\";\n const status =\n extractYamlValue(parsed.frontmatter, \"status\") ?? \"active\";\n\n return { slug, registered, status };\n}\n\n/**\n * Scan a list of parent directories for `<child>/Notes/PAI.md` marker files.\n * Each directory in `searchDirs` is scanned one level deep — its immediate\n * child directories are checked for a `Notes/PAI.md` file.\n *\n * Returns an array of PaiMarker objects for every valid marker found.\n * Invalid or malformed markers are silently skipped.\n *\n * @param searchDirs Absolute paths to parent directories.\n */\nexport function discoverPaiMarkers(searchDirs: string[]): PaiMarker[] {\n const results: PaiMarker[] = [];\n\n for (const dir of searchDirs) {\n if (!existsSync(dir)) continue;\n\n let children: string[];\n try {\n children = readdirSync(dir);\n } catch {\n continue;\n }\n\n for (const child of children) {\n if (child.startsWith(\".\")) continue;\n const childPath = join(dir, child);\n try {\n if (!statSync(childPath).isDirectory()) continue;\n } catch {\n continue;\n }\n\n const markerData = readPaiMarker(childPath);\n if (!markerData) continue;\n\n results.push({\n path: join(childPath, \"Notes\", \"PAI.md\"),\n slug: markerData.slug,\n projectRoot: childPath,\n });\n }\n }\n\n return results;\n}\n"],"mappings":"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"}