@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.
package/dist/cli/index.mjs
CHANGED
|
@@ -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-
|
|
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());
|
package/dist/daemon/index.mjs
CHANGED
|
@@ -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-
|
|
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
|
|
1429
|
+
* Build the prompt string to send to the summarizer model.
|
|
1408
1430
|
*
|
|
1409
|
-
* Returns a single string suitable for piping to `claude --model
|
|
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
|
-
|
|
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.
|
|
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$
|
|
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$
|
|
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,
|
|
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
|
-
|
|
1801
|
-
|
|
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
|
-
*
|
|
1963
|
-
* are stubs — they log and
|
|
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-
|
|
3046
|
+
//# sourceMappingURL=daemon-B8pkxhSc.mjs.map
|