@tekmidian/pai 0.7.8 → 0.8.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.
@@ -3793,7 +3793,7 @@ function cmdLogs(opts) {
3793
3793
  }
3794
3794
  function registerDaemonCommands(daemonCmd) {
3795
3795
  daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
3796
- const { serve } = await import("../daemon-BHfZR8Tc.mjs").then((n) => n.t);
3796
+ const { serve } = await import("../daemon-B8pkxhSc.mjs").then((n) => n.t);
3797
3797
  const { loadConfig: lc, ensureConfigDir } = await import("../config-BuhHWyOK.mjs").then((n) => n.r);
3798
3798
  ensureConfigDir();
3799
3799
  await serve(lc());
@@ -8,7 +8,7 @@ import "../indexer-D53l5d1U.mjs";
8
8
  import { t as PaiClient } from "../ipc-client-CoyUHPod.mjs";
9
9
  import { i as ensureConfigDir, o as loadConfig } from "../config-BuhHWyOK.mjs";
10
10
  import "../factory-Ygqe_bVZ.mjs";
11
- import { n as serve } from "../daemon-BHfZR8Tc.mjs";
11
+ import { n as serve } from "../daemon-B8pkxhSc.mjs";
12
12
  import "../state-C6_vqz7w.mjs";
13
13
  import "../tools-DcaJlYDN.mjs";
14
14
  import "../detector-jGBuYQJM.mjs";
@@ -1198,6 +1198,28 @@ function createSessionNote(notesDir, description) {
1198
1198
  console.error(`Created session note: ${filename}`);
1199
1199
  return filepath;
1200
1200
  }
1201
+ /** Append a checkpoint to the current session note. */
1202
+ function appendCheckpoint(notePath, checkpoint) {
1203
+ if (!existsSync$1(notePath)) {
1204
+ console.error(`Note file not found, recreating: ${notePath}`);
1205
+ try {
1206
+ const parentDir = join$1(notePath, "..");
1207
+ if (!existsSync$1(parentDir)) mkdirSync$1(parentDir, { recursive: true });
1208
+ const noteFilename = basename$1(notePath);
1209
+ const numberMatch = noteFilename.match(/^(\d+)/);
1210
+ writeFileSync$1(notePath, `# Session ${numberMatch ? numberMatch[1] : "0000"}: Recovered\n\n**Date:** ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}\n**Status:** In Progress\n\n---\n\n## Work Done\n\n<!-- PAI will add completed work here during session -->\n\n---\n\n## Next Steps\n\n<!-- To be filled at session end -->\n\n---\n\n**Tags:** #Session\n`);
1211
+ console.error(`Recreated session note: ${noteFilename}`);
1212
+ } catch (err) {
1213
+ console.error(`Failed to recreate note: ${err}`);
1214
+ return;
1215
+ }
1216
+ }
1217
+ const content = readFileSync$1(notePath, "utf-8");
1218
+ const checkpointText = `\n### Checkpoint ${(/* @__PURE__ */ new Date()).toISOString()}\n\n${checkpoint}\n`;
1219
+ const nextStepsIndex = content.indexOf("## Next Steps");
1220
+ writeFileSync$1(notePath, nextStepsIndex !== -1 ? content.substring(0, nextStepsIndex) + checkpointText + content.substring(nextStepsIndex) : content + checkpointText);
1221
+ console.error(`Checkpoint added to: ${basename$1(notePath)}`);
1222
+ }
1201
1223
  /** Add work items to the "Work Done" section of a session note. */
1202
1224
  function addWorkToSessionNote(notePath, workItems, sectionTitle) {
1203
1225
  if (!existsSync$1(notePath)) {
@@ -1404,9 +1426,9 @@ ${stateLines}
1404
1426
  //#endregion
1405
1427
  //#region src/daemon/templates/session-summary-prompt.ts
1406
1428
  /**
1407
- * Build the prompt string to send to Haiku for session summarization.
1429
+ * Build the prompt string to send to the summarizer model.
1408
1430
  *
1409
- * Returns a single string suitable for piping to `claude --model haiku --print`.
1431
+ * Returns a single string suitable for piping to `claude --model <model> --print`.
1410
1432
  */
1411
1433
  function buildSessionSummaryPrompt(params) {
1412
1434
  const { userMessages, gitLog, cwd, date, filesModified, existingNote } = params;
@@ -1438,7 +1460,9 @@ ${existingNote}
1438
1460
  ` : ""}
1439
1461
  Format your response EXACTLY as follows (no extra text before or after):
1440
1462
 
1441
- # Session: [Descriptive Title - 5-8 words summarizing the main accomplishment]
1463
+ TOPIC: [A short topic label, max 60 characters, describing the WORK DONE — not quoting user messages. Format as "Topic1, Topic2, and Topic3" if multiple themes. Example: "Session Summary Worker, Topic Detection"]
1464
+
1465
+ # Session: [Descriptive title summarizing what was ACCOMPLISHED, max 60 characters. Describe the work done, not the user's request. Bad: "Dark Mode Button Does Nothing". Good: "Dark Mode Toggle, Keyboard IPC, and Audio Fix"]
1442
1466
 
1443
1467
  **Date:** ${date}
1444
1468
  **Status:** In Progress
@@ -1481,7 +1505,12 @@ ${filesSection ? `\nFILES MODIFIED:\n${filesSection}` : ""}`;
1481
1505
  * 2. Extracting user messages and assistant context
1482
1506
  * 3. Gathering git commits from the session period
1483
1507
  * 4. Spawning Claude (sonnet for compaction, opus for session end) to generate a structured summary
1484
- * 5. Writing the summary to the project's session note
1508
+ * 5. Comparing the new topic against the existing note's topic
1509
+ * 6. Creating a NEW note if the topic shifted, or updating the existing one
1510
+ *
1511
+ * Topic detection: the summarizer outputs a TOPIC: line as the first line of
1512
+ * its response. This is compared against the existing note's title using word
1513
+ * overlap. If overlap is below ~30%, a new note is created.
1485
1514
  *
1486
1515
  * Designed to run inside the daemon's work queue worker. All errors are
1487
1516
  * thrown (not swallowed) so the work queue retry logic handles them.
@@ -1620,7 +1649,7 @@ function extractFromJsonl(jsonlPath, model = "sonnet") {
1620
1649
  if (entry.type === "user") {
1621
1650
  const msg = entry.message;
1622
1651
  if (msg?.content) {
1623
- const text = contentToText$1(msg.content);
1652
+ const text = contentToText$2(msg.content);
1624
1653
  if (text && !isNoise(text) && !seenMessages.has(text)) {
1625
1654
  seenMessages.add(text);
1626
1655
  result.userMessages.push(text.slice(0, 500));
@@ -1645,7 +1674,7 @@ function extractFromJsonl(jsonlPath, model = "sonnet") {
1645
1674
  return result;
1646
1675
  }
1647
1676
  /** Convert Claude content (string or content block array) to plain text. */
1648
- function contentToText$1(content) {
1677
+ function contentToText$2(content) {
1649
1678
  if (typeof content === "string") return content;
1650
1679
  if (Array.isArray(content)) return content.map((c) => {
1651
1680
  if (typeof c === "string") return c;
@@ -1782,23 +1811,142 @@ async function spawnSummarizer(prompt, model = "sonnet") {
1782
1811
  });
1783
1812
  }
1784
1813
  /**
1814
+ * Extract the TOPIC: line from the summarizer output.
1815
+ * Returns the topic string, or null if not found.
1816
+ */
1817
+ function extractTopic(summaryText) {
1818
+ const match = summaryText.match(/^TOPIC:\s*(.+)$/m);
1819
+ if (!match) return null;
1820
+ return match[1].trim();
1821
+ }
1822
+ /**
1823
+ * Extract the title/topic from an existing session note.
1824
+ * Looks at the H1 "# Session NNNN: Title" line.
1825
+ */
1826
+ function extractExistingNoteTitle(notePath) {
1827
+ try {
1828
+ const match = readFileSync(notePath, "utf-8").match(/^# Session \d+:\s*(.+)$/m);
1829
+ if (match) return match[1].trim();
1830
+ } catch {}
1831
+ return null;
1832
+ }
1833
+ /**
1834
+ * Compute word overlap ratio between two topic strings.
1835
+ * Returns a value in [0, 1] — 1.0 means identical word sets.
1836
+ *
1837
+ * Uses lowercased, normalized words. Stop words and very short words
1838
+ * are excluded to avoid false positives on common terms.
1839
+ */
1840
+ function computeTopicOverlap(topicA, topicB) {
1841
+ const stopWords = new Set([
1842
+ "a",
1843
+ "an",
1844
+ "the",
1845
+ "and",
1846
+ "or",
1847
+ "but",
1848
+ "in",
1849
+ "on",
1850
+ "at",
1851
+ "to",
1852
+ "for",
1853
+ "of",
1854
+ "with",
1855
+ "by",
1856
+ "from",
1857
+ "is",
1858
+ "was",
1859
+ "are",
1860
+ "were",
1861
+ "be",
1862
+ "been",
1863
+ "being",
1864
+ "have",
1865
+ "has",
1866
+ "had",
1867
+ "do",
1868
+ "does",
1869
+ "did",
1870
+ "will",
1871
+ "would",
1872
+ "could",
1873
+ "should",
1874
+ "may",
1875
+ "might",
1876
+ "can",
1877
+ "shall",
1878
+ "this",
1879
+ "that",
1880
+ "these",
1881
+ "those",
1882
+ "it",
1883
+ "its",
1884
+ "new",
1885
+ "session",
1886
+ "work",
1887
+ "done"
1888
+ ]);
1889
+ const normalize = (text) => {
1890
+ const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
1891
+ return new Set(words);
1892
+ };
1893
+ const wordsA = normalize(topicA);
1894
+ const wordsB = normalize(topicB);
1895
+ if (wordsA.size === 0 || wordsB.size === 0) return 0;
1896
+ let intersection = 0;
1897
+ for (const w of wordsA) if (wordsB.has(w)) intersection++;
1898
+ const union = new Set([...wordsA, ...wordsB]).size;
1899
+ return union > 0 ? intersection / union : 0;
1900
+ }
1901
+ /** Threshold: below this overlap ratio, we consider topics different. */
1902
+ const TOPIC_OVERLAP_THRESHOLD = .3;
1903
+ /**
1785
1904
  * Write (or update) the session note with the AI-generated summary.
1786
1905
  *
1787
1906
  * Strategy:
1788
1907
  * - Find the current month's latest note
1789
- * - If it's from today, update it with the new summary
1908
+ * - If it's from today, compare topics:
1909
+ * - Same topic (overlap >= 30%) → update existing note
1910
+ * - Different topic (overlap < 30%) → create a NEW note
1790
1911
  * - If it's from a different day, create a new note
1791
1912
  */
1792
1913
  function writeSessionNote(cwd, summaryText, filesModified) {
1793
1914
  const notesInfo = findNotesDir(cwd);
1794
1915
  let notePath = getCurrentNotePath(notesInfo.path);
1795
1916
  const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1917
+ const newTopic = extractTopic(summaryText);
1796
1918
  if (notePath) {
1797
1919
  const noteFilename = basename(notePath);
1798
1920
  const dateMatch = noteFilename.match(/(\d{4}-\d{2}-\d{2})/);
1799
1921
  if ((dateMatch ? dateMatch[1] : "") === today) {
1800
- updateNoteWithSummary(notePath, summaryText);
1801
- process.stderr.write(`[session-summary] Updated existing note: ${noteFilename}\n`);
1922
+ const existingTitle = extractExistingNoteTitle(notePath);
1923
+ let topicShifted = false;
1924
+ if (newTopic && existingTitle) {
1925
+ const overlap = computeTopicOverlap(newTopic, existingTitle);
1926
+ process.stderr.write(`[session-summary] Topic overlap: ${(overlap * 100).toFixed(1)}% (new="${newTopic}", existing="${existingTitle}")\n`);
1927
+ if (overlap < TOPIC_OVERLAP_THRESHOLD) {
1928
+ topicShifted = true;
1929
+ process.stderr.write(`[session-summary] Topic shift detected (word overlap) — creating new note.\n`);
1930
+ }
1931
+ }
1932
+ if (!topicShifted) {
1933
+ const boundaryPath = join(notesInfo.path, "topic-boundary.json");
1934
+ if (existsSync(boundaryPath)) try {
1935
+ const boundary = JSON.parse(readFileSync(boundaryPath, "utf-8"));
1936
+ if (boundary.timestamp) {
1937
+ if (Date.now() - new Date(boundary.timestamp).getTime() < 1800 * 1e3) {
1938
+ topicShifted = true;
1939
+ process.stderr.write(`[session-summary] Topic shift detected (boundary marker) — ${boundary.previousProject} → ${boundary.suggestedProject}\n`);
1940
+ }
1941
+ }
1942
+ unlinkSync(boundaryPath);
1943
+ } catch {}
1944
+ }
1945
+ if (topicShifted) notePath = createNoteFromSummary(notesInfo.path, summaryText);
1946
+ else {
1947
+ updateNoteWithSummary(notePath, summaryText);
1948
+ process.stderr.write(`[session-summary] Updated existing note: ${noteFilename}\n`);
1949
+ }
1802
1950
  } else notePath = createNoteFromSummary(notesInfo.path, summaryText);
1803
1951
  } else notePath = createNoteFromSummary(notesInfo.path, summaryText);
1804
1952
  if (notePath) {
@@ -1865,7 +2013,7 @@ function createNoteFromSummary(notesDir, summaryText) {
1865
2013
 
1866
2014
  ---
1867
2015
 
1868
- ${summaryText.replace(/^# Session:.*$/m, "").replace(/^\*\*Date:\*\*.*$/m, "").replace(/^\*\*Status:\*\*.*$/m, "").replace(/^---$/m, "").trim()}
2016
+ ${summaryText.replace(/^TOPIC:.*$/m, "").replace(/^# Session:.*$/m, "").replace(/^\*\*Date:\*\*.*$/m, "").replace(/^\*\*Status:\*\*.*$/m, "").replace(/^---$/m, "").trim()}
1869
2017
 
1870
2018
  ---
1871
2019
 
@@ -1949,6 +2097,129 @@ async function handleSessionSummary(payload) {
1949
2097
  process.stderr.write("[session-summary] Done.\n");
1950
2098
  }
1951
2099
 
2100
+ //#endregion
2101
+ //#region src/daemon/topic-detect-worker.ts
2102
+ /**
2103
+ * topic-detect-worker.ts — Topic shift detection for session note splitting
2104
+ *
2105
+ * Processes `topic-detect` work items by:
2106
+ * 1. Extracting recent user messages from the JSONL transcript
2107
+ * 2. Running the BM25-based topic shift detector against the PAI memory DB
2108
+ * 3. If a shift is detected, recording a topic boundary marker
2109
+ *
2110
+ * The actual note splitting is handled by session-summary-worker.ts when it
2111
+ * processes the next `session-summary` work item — it uses the TOPIC: line
2112
+ * from the summarizer to decide whether to create a new note.
2113
+ *
2114
+ * This worker provides an additional signal: project-level topic shift
2115
+ * (e.g., conversation moved from project A to project B). The session
2116
+ * summary worker handles intra-project topic shifts (e.g., from "dark mode"
2117
+ * to "keyboard IPC" within the same project).
2118
+ */
2119
+ const MAX_CONTEXT_MESSAGES = 5;
2120
+ const MAX_CONTEXT_CHARS = 2e3;
2121
+ /**
2122
+ * Extract recent user messages from a JSONL transcript for topic detection.
2123
+ * Takes only the last few messages to represent the current topic.
2124
+ */
2125
+ function extractRecentContext(jsonlPath) {
2126
+ try {
2127
+ const raw = readFileSync(jsonlPath, "utf-8");
2128
+ const lines = (raw.length > 5e4 ? raw.slice(-5e4) : raw).trim().split("\n");
2129
+ const messages = [];
2130
+ for (let i = lines.length - 1; i >= 0 && messages.length < MAX_CONTEXT_MESSAGES; i--) {
2131
+ const line = lines[i].trim();
2132
+ if (!line) continue;
2133
+ try {
2134
+ const entry = JSON.parse(line);
2135
+ if (entry.type === "user") {
2136
+ const msg = entry.message;
2137
+ if (msg?.content) {
2138
+ const text = contentToText$1(msg.content);
2139
+ if (text && text.length > 3) messages.unshift(text.slice(0, 500));
2140
+ }
2141
+ }
2142
+ } catch {}
2143
+ }
2144
+ return messages.join("\n\n").slice(0, MAX_CONTEXT_CHARS);
2145
+ } catch {
2146
+ return "";
2147
+ }
2148
+ }
2149
+ /** Convert Claude content (string or content block array) to plain text. */
2150
+ function contentToText$1(content) {
2151
+ if (typeof content === "string") return content;
2152
+ if (Array.isArray(content)) return content.map((c) => {
2153
+ if (typeof c === "string") return c;
2154
+ const block = c;
2155
+ if (block?.text) return String(block.text);
2156
+ if (block?.content) return String(block.content);
2157
+ return "";
2158
+ }).join(" ").trim();
2159
+ return "";
2160
+ }
2161
+ const TOPIC_BOUNDARY_FILE = "topic-boundary.json";
2162
+ /**
2163
+ * Write a topic boundary marker into the Notes directory.
2164
+ * The session-summary-worker checks for this file and uses it as an
2165
+ * additional signal that a new note should be created.
2166
+ */
2167
+ function writeTopicBoundary(cwd, boundary) {
2168
+ try {
2169
+ const boundaryPath = join(findNotesDir(cwd).path, TOPIC_BOUNDARY_FILE);
2170
+ writeFileSync(boundaryPath, JSON.stringify(boundary, null, 2), "utf-8");
2171
+ process.stderr.write(`[topic-detect] Wrote topic boundary marker: ${boundaryPath}\n`);
2172
+ } catch (e) {
2173
+ process.stderr.write(`[topic-detect] Could not write boundary marker: ${e}\n`);
2174
+ }
2175
+ }
2176
+ /**
2177
+ * Process a `topic-detect` work item.
2178
+ *
2179
+ * Called by work-queue-worker.ts. Throws on fatal errors so the work queue
2180
+ * retry logic handles them.
2181
+ */
2182
+ async function handleTopicDetect(payload) {
2183
+ const { cwd, currentProject, transcriptPath, sessionId } = payload;
2184
+ if (!cwd) throw new Error("topic-detect payload missing cwd");
2185
+ process.stderr.write(`[topic-detect] Starting for ${cwd}${currentProject ? ` (project=${currentProject})` : ""}${sessionId ? ` (session=${sessionId})` : ""}\n`);
2186
+ if (!registryDb || !storageBackend) {
2187
+ process.stderr.write("[topic-detect] Registry DB or storage backend not available — skipping.\n");
2188
+ return;
2189
+ }
2190
+ let context = payload.context || "";
2191
+ if (!context && transcriptPath && existsSync(transcriptPath)) context = extractRecentContext(transcriptPath);
2192
+ if (!context || context.trim().length < 10) {
2193
+ process.stderr.write("[topic-detect] Insufficient context for topic detection — skipping.\n");
2194
+ return;
2195
+ }
2196
+ process.stderr.write(`[topic-detect] Context: ${context.length} chars, checking against memory...\n`);
2197
+ const result = await detectTopicShift(registryDb, storageBackend, {
2198
+ context,
2199
+ currentProject,
2200
+ threshold: .6,
2201
+ candidates: 20
2202
+ });
2203
+ process.stderr.write(`[topic-detect] Result: shifted=${result.shifted}, suggested=${result.suggestedProject}, confidence=${result.confidence.toFixed(2)}, chunks=${result.chunkCount}\n`);
2204
+ if (result.topProjects.length > 0) process.stderr.write(`[topic-detect] Top projects: ${result.topProjects.map((p) => `${p.slug}(${(p.score * 100).toFixed(0)}%)`).join(", ")}\n`);
2205
+ if (result.shifted) {
2206
+ writeTopicBoundary(cwd, {
2207
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2208
+ previousProject: result.currentProject,
2209
+ suggestedProject: result.suggestedProject,
2210
+ confidence: result.confidence,
2211
+ context: context.slice(0, 200)
2212
+ });
2213
+ try {
2214
+ const notePath = getCurrentNotePath(findNotesDir(cwd).path);
2215
+ if (notePath) appendCheckpoint(notePath, `Topic shift detected: conversation moved from **${result.currentProject}** to **${result.suggestedProject}** (confidence: ${(result.confidence * 100).toFixed(0)}%). A new session note will be created for the new topic.`);
2216
+ } catch (e) {
2217
+ process.stderr.write(`[topic-detect] Could not append checkpoint: ${e}\n`);
2218
+ }
2219
+ }
2220
+ process.stderr.write("[topic-detect] Done.\n");
2221
+ }
2222
+
1952
2223
  //#endregion
1953
2224
  //#region src/daemon/work-queue-worker.ts
1954
2225
  /**
@@ -1959,8 +2230,9 @@ async function handleSessionSummary(payload) {
1959
2230
  * work summaries, updating the session note, and updating TODO.md.
1960
2231
  * Handles 'session-summary' items by spawning Haiku for AI-powered note generation.
1961
2232
  *
1962
- * Other item types (note-update, todo-update, topic-detect)
1963
- * are stubs — they log and complete immediately, ready for future expansion.
2233
+ * Handles 'topic-detect' items by running BM25-based topic shift detection.
2234
+ * Other item types (note-update, todo-update) are stubs — they log and
2235
+ * complete immediately, ready for future expansion.
1964
2236
  */
1965
2237
  const WORKER_INTERVAL_MS = 5e3;
1966
2238
  const HOUSEKEEPING_INTERVAL_MS = 600 * 1e3;
@@ -2015,9 +2287,11 @@ async function processNextItem() {
2015
2287
  case "session-summary":
2016
2288
  await handleSessionSummary(item.payload);
2017
2289
  break;
2290
+ case "topic-detect":
2291
+ await handleTopicDetect(item.payload);
2292
+ break;
2018
2293
  case "note-update":
2019
2294
  case "todo-update":
2020
- case "topic-detect":
2021
2295
  process.stderr.write(`[work-queue-worker] Item type '${item.type}' is not yet implemented — completing as no-op.\n`);
2022
2296
  break;
2023
2297
  default: throw new Error(`Unknown work item type: ${item.type}`);
@@ -2769,4 +3043,4 @@ var daemon_exports = /* @__PURE__ */ __exportAll({ serve: () => serve });
2769
3043
 
2770
3044
  //#endregion
2771
3045
  export { serve as n, daemon_exports as t };
2772
- //# sourceMappingURL=daemon-BHfZR8Tc.mjs.map
3046
+ //# sourceMappingURL=daemon-B8pkxhSc.mjs.map