@tiey/synth 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/dist/index.js +1831 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1831 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/config/loader.ts
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import { parse as parseYaml } from "yaml";
|
|
11
|
+
|
|
12
|
+
// src/config/schema.ts
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
var llmConfigSchema = z.object({
|
|
15
|
+
provider: z.enum(["openai-compatible", "anthropic", "ollama"]).default("openai-compatible"),
|
|
16
|
+
baseUrl: z.string().default("https://api.openai.com/v1"),
|
|
17
|
+
apiKey: z.string().default(""),
|
|
18
|
+
model: z.string().default("gpt-4o"),
|
|
19
|
+
contextWindow: z.number().int().positive().default(128e3),
|
|
20
|
+
maxTokens: z.number().int().positive().default(4096),
|
|
21
|
+
maxConcurrency: z.number().int().positive().default(5),
|
|
22
|
+
headers: z.record(z.string(), z.string()).default({})
|
|
23
|
+
});
|
|
24
|
+
var agentConfigSchema = z.object({
|
|
25
|
+
role: z.string().default(""),
|
|
26
|
+
systemPrompt: z.string().default(""),
|
|
27
|
+
mapPrompt: z.string().default(""),
|
|
28
|
+
reducePrompt: z.string().default(""),
|
|
29
|
+
reportPrompt: z.string().default("")
|
|
30
|
+
});
|
|
31
|
+
var scheduleConfigSchema = z.object({
|
|
32
|
+
cron: z.string().default("0 1-23 * * *")
|
|
33
|
+
});
|
|
34
|
+
var collectorConfigSchema = z.object({
|
|
35
|
+
adapters: z.array(z.string()).default(["claude-code", "codex", "qoder"])
|
|
36
|
+
});
|
|
37
|
+
var outputConfigSchema = z.object({
|
|
38
|
+
formats: z.array(z.enum(["markdown", "html"])).default(["markdown", "html"]),
|
|
39
|
+
dir: z.string().default("~/.synth/reports")
|
|
40
|
+
});
|
|
41
|
+
var defaultLlmConfig = llmConfigSchema.parse({});
|
|
42
|
+
var defaultAgentConfig = agentConfigSchema.parse({});
|
|
43
|
+
var defaultScheduleConfig = scheduleConfigSchema.parse({});
|
|
44
|
+
var defaultCollectorConfig = collectorConfigSchema.parse({});
|
|
45
|
+
var defaultOutputConfig = outputConfigSchema.parse({});
|
|
46
|
+
var configSchema = z.object({
|
|
47
|
+
llm: llmConfigSchema.default(defaultLlmConfig),
|
|
48
|
+
agent: agentConfigSchema.default(defaultAgentConfig),
|
|
49
|
+
schedule: scheduleConfigSchema.default(defaultScheduleConfig),
|
|
50
|
+
collector: collectorConfigSchema.default(defaultCollectorConfig),
|
|
51
|
+
output: outputConfigSchema.default(defaultOutputConfig)
|
|
52
|
+
});
|
|
53
|
+
var defaultConfigYaml = `# Synth config
|
|
54
|
+
|
|
55
|
+
llm:
|
|
56
|
+
provider: "openai-compatible"
|
|
57
|
+
baseUrl: "https://api.openai.com/v1"
|
|
58
|
+
apiKey: ""
|
|
59
|
+
model: "gpt-4o"
|
|
60
|
+
contextWindow: 128000
|
|
61
|
+
maxTokens: 4096
|
|
62
|
+
maxConcurrency: 5
|
|
63
|
+
headers: {}
|
|
64
|
+
|
|
65
|
+
agent:
|
|
66
|
+
role: ""
|
|
67
|
+
systemPrompt: ""
|
|
68
|
+
mapPrompt: ""
|
|
69
|
+
reducePrompt: ""
|
|
70
|
+
reportPrompt: ""
|
|
71
|
+
|
|
72
|
+
schedule:
|
|
73
|
+
cron: "0 1-23 * * *"
|
|
74
|
+
|
|
75
|
+
collector:
|
|
76
|
+
adapters:
|
|
77
|
+
- claude-code
|
|
78
|
+
- codex
|
|
79
|
+
- qoder
|
|
80
|
+
|
|
81
|
+
output:
|
|
82
|
+
formats:
|
|
83
|
+
- markdown
|
|
84
|
+
- html
|
|
85
|
+
dir: "~/.synth/reports"
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
// src/config/loader.ts
|
|
89
|
+
function getSynthDir() {
|
|
90
|
+
return path.join(os.homedir(), ".synth");
|
|
91
|
+
}
|
|
92
|
+
function getConfigPath() {
|
|
93
|
+
return path.join(getSynthDir(), "config.yaml");
|
|
94
|
+
}
|
|
95
|
+
function initConfig() {
|
|
96
|
+
const configPath = getConfigPath();
|
|
97
|
+
const dir = path.dirname(configPath);
|
|
98
|
+
if (!fs.existsSync(dir)) {
|
|
99
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
if (fs.existsSync(configPath)) {
|
|
102
|
+
return `\u914D\u7F6E\u6587\u4EF6\u5DF2\u5B58\u5728: ${configPath}`;
|
|
103
|
+
}
|
|
104
|
+
fs.writeFileSync(configPath, defaultConfigYaml, "utf-8");
|
|
105
|
+
return `\u914D\u7F6E\u6587\u4EF6\u5DF2\u751F\u6210: ${configPath}`;
|
|
106
|
+
}
|
|
107
|
+
function loadConfig() {
|
|
108
|
+
const configPath = getConfigPath();
|
|
109
|
+
let raw = {};
|
|
110
|
+
if (fs.existsSync(configPath)) {
|
|
111
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
112
|
+
raw = parseYaml(content) ?? {};
|
|
113
|
+
}
|
|
114
|
+
return configSchema.parse(raw);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/runner.ts
|
|
118
|
+
import { createHash } from "crypto";
|
|
119
|
+
import fs9 from "fs";
|
|
120
|
+
import os7 from "os";
|
|
121
|
+
import path9 from "path";
|
|
122
|
+
|
|
123
|
+
// src/agent/prompts.ts
|
|
124
|
+
var defaultPrompts = {
|
|
125
|
+
role: "AI\u7F16\u7A0B\u5DE5\u4F5C\u8BB0\u5F55\u5206\u6790\u5E08",
|
|
126
|
+
systemPrompt: `\u4F60\u662F\u4E00\u4F4D\u4E13\u4E1A\u7684\u5DE5\u4F5C\u5185\u5BB9\u5206\u6790\u5E08\u3002\u4F60\u7684\u4EFB\u52A1\u662F\u5206\u6790\u7528\u6237\u4E0EAI\u7F16\u7A0B\u52A9\u624B\u7684\u5BF9\u8BDD\u8BB0\u5F55\uFF0C
|
|
127
|
+
\u63D0\u53D6\u5173\u952E\u5DE5\u4F5C\u5185\u5BB9\uFF0C\u751F\u6210\u7B80\u6D01\u51C6\u786E\u7684\u5DE5\u4F5C\u6458\u8981\u3002
|
|
128
|
+
\u8981\u6C42\uFF1A
|
|
129
|
+
- \u5173\u6CE8\u5B9E\u9645\u5B8C\u6210\u7684\u5DE5\u4F5C\uFF0C\u800C\u975E\u5BF9\u8BDD\u8FC7\u7A0B
|
|
130
|
+
- \u63D0\u53D6\u4FEE\u6539\u7684\u6587\u4EF6\u3001\u89E3\u51B3\u7684\u95EE\u9898\u3001\u5B9E\u73B0\u7684\u529F\u80FD
|
|
131
|
+
- \u4F7F\u7528\u7B80\u6D01\u7684\u4E2D\u6587\u63CF\u8FF0
|
|
132
|
+
- \u5FFD\u7565\u95F2\u804A\u3001\u7CFB\u7EDF\u63D0\u793A\u7B49\u65E0\u5173\u5185\u5BB9`,
|
|
133
|
+
mapPrompt: `\u4EE5\u4E0B\u662F {{tool}} \u4E2D\u7684\u4E00\u6BB5\u5BF9\u8BDD\u8BB0\u5F55\uFF08\u7B2C {{chunkIndex}}/{{totalChunks}} \u7247\uFF09\u3002
|
|
134
|
+
\u5DE5\u4F5C\u76EE\u5F55\uFF1A{{workingDir}}
|
|
135
|
+
\u65F6\u95F4\u8303\u56F4\uFF1A{{startTime}} ~ {{endTime}}
|
|
136
|
+
\u8BF7\u63D0\u53D6\u672C\u7247\u6BB5\u4E2D\u7684\u5173\u952E\u5DE5\u4F5C\u5185\u5BB9\uFF0C\u4EE5\u8981\u70B9\u5217\u8868\u5F62\u5F0F\u8F93\u51FA\u3002
|
|
137
|
+
---
|
|
138
|
+
{{content}}`,
|
|
139
|
+
reducePrompt: `\u4EE5\u4E0B\u662F\u540C\u4E00\u4F1A\u8BDD\u62C6\u5206\u540E\u7684\u5404\u7247\u6BB5\u6458\u8981\uFF0C\u8BF7\u5408\u5E76\u4E3A\u4E00\u4EFD\u5B8C\u6574\u6458\u8981\uFF0C\u53BB\u91CD\u5E76\u6309\u65F6\u95F4\u7EBF\u7EC4\u7EC7\uFF1A
|
|
140
|
+
{{summaries}}`,
|
|
141
|
+
reportPrompt: `\u8BF7\u5C06\u4EE5\u4E0B\u4F1A\u8BDD\u6458\u8981\u6574\u5408\u4E3A\u5DE5\u4F5C\u65E5\u62A5\uFF0C\u4E25\u683C\u6309\u7167\u6307\u5B9A\u683C\u5F0F\u8F93\u51FA\u3002
|
|
142
|
+
\u65E5\u671F\uFF1A{{date}}
|
|
143
|
+
\u8981\u6C42\u683C\u5F0F\uFF1A
|
|
144
|
+
# {{date}} \u5DE5\u4F5C\u65E5\u62A5
|
|
145
|
+
## <\u5DE5\u4F5C\u76EE\u5F55>
|
|
146
|
+
### <\u65F6\u95F4\u6BB5>
|
|
147
|
+
- <\u5DE5\u4F5C\u5185\u5BB9>
|
|
148
|
+
\u5408\u5E76\u89C4\u5219\uFF1A
|
|
149
|
+
- \u540C\u4E00\u5DE5\u4F5C\u76EE\u5F55\u4E0B\u7684\u591A\u4E2A\u4F1A\u8BDD\uFF0C\u6309\u65F6\u95F4\u6BB5\u5206\u522B\u5217\u51FA
|
|
150
|
+
- \u65F6\u95F4\u6BB5\u683C\u5F0F\u4E3A HH:MM~HH:MM
|
|
151
|
+
---
|
|
152
|
+
{{sessionSummaries}}`
|
|
153
|
+
};
|
|
154
|
+
function renderTemplate(template, vars) {
|
|
155
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/processor/preprocessor.ts
|
|
159
|
+
var BASE64_PATTERN = /data:[a-zA-Z0-9+/]+;base64,[A-Za-z0-9+/=]{100,}/g;
|
|
160
|
+
var SYSTEM_REMINDER_PATTERN = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
|
|
161
|
+
var DEFERRED_TOOLS_PATTERN = /<functions>[\s\S]*?<\/functions>/g;
|
|
162
|
+
var TOOL_RESULT_MAX_LEN = 200;
|
|
163
|
+
function preprocessMessages(messages) {
|
|
164
|
+
const result = [];
|
|
165
|
+
for (const msg of messages) {
|
|
166
|
+
const cleaned = cleanContent(msg.content);
|
|
167
|
+
if (!cleaned.trim()) continue;
|
|
168
|
+
result.push({
|
|
169
|
+
role: msg.role,
|
|
170
|
+
content: cleaned,
|
|
171
|
+
timestamp: msg.timestamp
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
function cleanContent(content) {
|
|
177
|
+
let text = content;
|
|
178
|
+
text = text.replace(/\[thinking\][^\n]*/g, "");
|
|
179
|
+
text = text.replace(BASE64_PATTERN, "[base64 content removed]");
|
|
180
|
+
text = text.replace(SYSTEM_REMINDER_PATTERN, "");
|
|
181
|
+
text = text.replace(DEFERRED_TOOLS_PATTERN, "");
|
|
182
|
+
text = text.replace(/<attachment>[\s\S]*?<\/attachment>/g, "");
|
|
183
|
+
text = text.replace(/\[tool_result\]\s*([\s\S]*?)(?=\n\[|$)/g, (match, resultContent) => {
|
|
184
|
+
const trimmed = resultContent.trim();
|
|
185
|
+
if (trimmed.length > TOOL_RESULT_MAX_LEN) {
|
|
186
|
+
return `[tool_result] ${trimmed.slice(0, TOOL_RESULT_MAX_LEN)}...`;
|
|
187
|
+
}
|
|
188
|
+
return `[tool_result] ${trimmed}`;
|
|
189
|
+
});
|
|
190
|
+
text = text.replace(/\[tool_use:\s*(\w+)\]\s*([\s\S]*?)(?=\n\[|$)/g, (match, name, input) => {
|
|
191
|
+
const trimmedInput = input.trim();
|
|
192
|
+
if (trimmedInput.length > 500) {
|
|
193
|
+
return `[tool_use: ${name}] ${trimmedInput.slice(0, 500)}...`;
|
|
194
|
+
}
|
|
195
|
+
return `[tool_use: ${name}] ${trimmedInput}`;
|
|
196
|
+
});
|
|
197
|
+
text = text.replace(/\n{3,}/g, "\n\n");
|
|
198
|
+
return text.trim();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/processor/chunker.ts
|
|
202
|
+
var DEFAULT_OPTIONS = {
|
|
203
|
+
maxTokensPerChunk: 5e4,
|
|
204
|
+
overlapMessages: 3
|
|
205
|
+
};
|
|
206
|
+
function estimateTokens(text) {
|
|
207
|
+
return Math.ceil(text.length / 3);
|
|
208
|
+
}
|
|
209
|
+
function estimateMessagesTokens(messages) {
|
|
210
|
+
return messages.reduce((sum, m) => sum + estimateTokens(m.content), 0);
|
|
211
|
+
}
|
|
212
|
+
function chunkMessages(messages, options) {
|
|
213
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
214
|
+
const totalTokens = estimateMessagesTokens(messages);
|
|
215
|
+
if (totalTokens <= opts.maxTokensPerChunk) {
|
|
216
|
+
return [{
|
|
217
|
+
messages,
|
|
218
|
+
chunkIndex: 1,
|
|
219
|
+
totalChunks: 1,
|
|
220
|
+
startTime: messages[0].timestamp,
|
|
221
|
+
endTime: messages[messages.length - 1].timestamp
|
|
222
|
+
}];
|
|
223
|
+
}
|
|
224
|
+
const pairBoundaries = [0];
|
|
225
|
+
for (let i = 1; i < messages.length; i++) {
|
|
226
|
+
if (messages[i].role === "user") {
|
|
227
|
+
pairBoundaries.push(i);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const rawChunks = [];
|
|
231
|
+
let currentChunk = [];
|
|
232
|
+
let currentTokens = 0;
|
|
233
|
+
for (let p = 0; p < pairBoundaries.length; p++) {
|
|
234
|
+
const pairStart = pairBoundaries[p];
|
|
235
|
+
const pairEnd = p + 1 < pairBoundaries.length ? pairBoundaries[p + 1] : messages.length;
|
|
236
|
+
const pairMessages = messages.slice(pairStart, pairEnd);
|
|
237
|
+
const pairTokens = pairMessages.reduce((sum, m) => sum + estimateTokens(m.content), 0);
|
|
238
|
+
if (currentChunk.length > 0 && currentTokens + pairTokens > opts.maxTokensPerChunk) {
|
|
239
|
+
rawChunks.push(currentChunk);
|
|
240
|
+
currentChunk = [];
|
|
241
|
+
currentTokens = 0;
|
|
242
|
+
}
|
|
243
|
+
currentChunk.push(...pairMessages);
|
|
244
|
+
currentTokens += pairTokens;
|
|
245
|
+
}
|
|
246
|
+
if (currentChunk.length > 0) {
|
|
247
|
+
rawChunks.push(currentChunk);
|
|
248
|
+
}
|
|
249
|
+
const totalChunks = rawChunks.length;
|
|
250
|
+
const chunks = rawChunks.map((chunkMsgs, i) => {
|
|
251
|
+
let finalMessages = chunkMsgs;
|
|
252
|
+
if (i > 0 && opts.overlapMessages > 0) {
|
|
253
|
+
const prevChunk = rawChunks[i - 1];
|
|
254
|
+
const overlapStart = Math.max(0, prevChunk.length - opts.overlapMessages);
|
|
255
|
+
const overlap = prevChunk.slice(overlapStart);
|
|
256
|
+
finalMessages = [...overlap, ...chunkMsgs];
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
messages: finalMessages,
|
|
260
|
+
chunkIndex: i + 1,
|
|
261
|
+
totalChunks,
|
|
262
|
+
startTime: finalMessages[0].timestamp,
|
|
263
|
+
endTime: finalMessages[finalMessages.length - 1].timestamp
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
return chunks;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/agent/semaphore.ts
|
|
270
|
+
var Semaphore = class {
|
|
271
|
+
constructor(max) {
|
|
272
|
+
this.max = max;
|
|
273
|
+
}
|
|
274
|
+
max;
|
|
275
|
+
queue = [];
|
|
276
|
+
running = 0;
|
|
277
|
+
async acquire() {
|
|
278
|
+
if (this.running < this.max) {
|
|
279
|
+
this.running++;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
return new Promise((resolve) => this.queue.push(resolve));
|
|
283
|
+
}
|
|
284
|
+
release() {
|
|
285
|
+
this.running--;
|
|
286
|
+
const next = this.queue.shift();
|
|
287
|
+
if (next) {
|
|
288
|
+
this.running++;
|
|
289
|
+
next();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// src/logger.ts
|
|
295
|
+
import fs2 from "fs";
|
|
296
|
+
import path2 from "path";
|
|
297
|
+
import pino from "pino";
|
|
298
|
+
function ensureLogDir() {
|
|
299
|
+
const logDir = path2.join(getSynthDir(), "logs");
|
|
300
|
+
if (!fs2.existsSync(logDir)) {
|
|
301
|
+
fs2.mkdirSync(logDir, { recursive: true });
|
|
302
|
+
}
|
|
303
|
+
return logDir;
|
|
304
|
+
}
|
|
305
|
+
function createLogger(level = "info") {
|
|
306
|
+
const logDir = ensureLogDir();
|
|
307
|
+
const logFile = path2.join(logDir, "synth.log");
|
|
308
|
+
return pino(
|
|
309
|
+
{ level },
|
|
310
|
+
pino.transport({
|
|
311
|
+
targets: [
|
|
312
|
+
{
|
|
313
|
+
target: "pino/file",
|
|
314
|
+
options: { destination: logFile, mkdir: true },
|
|
315
|
+
level
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
target: "pino-pretty",
|
|
319
|
+
options: { colorize: true },
|
|
320
|
+
level
|
|
321
|
+
}
|
|
322
|
+
]
|
|
323
|
+
})
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
var _logger;
|
|
327
|
+
function getLogger() {
|
|
328
|
+
if (!_logger) {
|
|
329
|
+
_logger = createLogger();
|
|
330
|
+
}
|
|
331
|
+
return _logger;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/agent/summarizer.ts
|
|
335
|
+
var MAX_RETRIES = 2;
|
|
336
|
+
function formatTime(date) {
|
|
337
|
+
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
338
|
+
}
|
|
339
|
+
function getPrompt(config, key) {
|
|
340
|
+
const custom = config.agent[key];
|
|
341
|
+
return custom && custom.trim() ? custom : defaultPrompts[key];
|
|
342
|
+
}
|
|
343
|
+
function messagesToText(messages) {
|
|
344
|
+
return messages.map((m) => `[${m.role}] ${m.content}`).join("\n\n");
|
|
345
|
+
}
|
|
346
|
+
var Summarizer = class {
|
|
347
|
+
provider;
|
|
348
|
+
config;
|
|
349
|
+
semaphore;
|
|
350
|
+
constructor(provider, config) {
|
|
351
|
+
this.provider = provider;
|
|
352
|
+
this.config = config;
|
|
353
|
+
this.semaphore = new Semaphore(config.llm.maxConcurrency);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Summarize a single session: preprocess → chunk if needed → summarize.
|
|
357
|
+
*/
|
|
358
|
+
async summarizeSession(session) {
|
|
359
|
+
const logger = getLogger();
|
|
360
|
+
const preprocessed = preprocessMessages(session.messages);
|
|
361
|
+
const totalTokens = estimateMessagesTokens(preprocessed);
|
|
362
|
+
const systemPrompt = getPrompt(this.config, "systemPrompt");
|
|
363
|
+
const threshold = this.config.llm.contextWindow - this.config.llm.maxTokens - 2e3;
|
|
364
|
+
logger.debug({
|
|
365
|
+
sessionId: session.sessionId.slice(0, 8),
|
|
366
|
+
totalTokens,
|
|
367
|
+
threshold,
|
|
368
|
+
messageCount: preprocessed.length
|
|
369
|
+
}, "Session \u6458\u8981\u5F00\u59CB");
|
|
370
|
+
let summary;
|
|
371
|
+
if (totalTokens <= threshold) {
|
|
372
|
+
summary = await this.singlePassSummarize(session, preprocessed, systemPrompt);
|
|
373
|
+
} else {
|
|
374
|
+
summary = await this.mapReduceSummarize(session, preprocessed, systemPrompt, threshold);
|
|
375
|
+
}
|
|
376
|
+
logger.info({
|
|
377
|
+
sessionId: session.sessionId.slice(0, 8),
|
|
378
|
+
tool: session.tool,
|
|
379
|
+
workingDir: session.workingDir
|
|
380
|
+
}, "Session \u6458\u8981\u5B8C\u6210");
|
|
381
|
+
return {
|
|
382
|
+
sessionId: session.sessionId,
|
|
383
|
+
tool: session.tool,
|
|
384
|
+
workingDir: session.workingDir,
|
|
385
|
+
startTime: session.startTime,
|
|
386
|
+
endTime: session.endTime,
|
|
387
|
+
summary
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
async singlePassSummarize(session, messages, systemPrompt) {
|
|
391
|
+
const content = messagesToText(messages);
|
|
392
|
+
const mapPrompt = renderTemplate(getPrompt(this.config, "mapPrompt"), {
|
|
393
|
+
tool: session.tool,
|
|
394
|
+
workingDir: session.workingDir,
|
|
395
|
+
startTime: formatTime(session.startTime),
|
|
396
|
+
endTime: formatTime(session.endTime),
|
|
397
|
+
chunkIndex: "1",
|
|
398
|
+
totalChunks: "1",
|
|
399
|
+
content
|
|
400
|
+
});
|
|
401
|
+
return this.callWithRetry([
|
|
402
|
+
{ role: "system", content: systemPrompt },
|
|
403
|
+
{ role: "user", content: mapPrompt }
|
|
404
|
+
]);
|
|
405
|
+
}
|
|
406
|
+
async mapReduceSummarize(session, messages, systemPrompt, maxTokensPerChunk) {
|
|
407
|
+
const logger = getLogger();
|
|
408
|
+
const chunks = chunkMessages(messages, { maxTokensPerChunk });
|
|
409
|
+
logger.info({
|
|
410
|
+
sessionId: session.sessionId.slice(0, 8),
|
|
411
|
+
chunks: chunks.length
|
|
412
|
+
}, "MapReduce \u5206\u7247");
|
|
413
|
+
const mapResults = await Promise.all(
|
|
414
|
+
chunks.map(async (chunk) => {
|
|
415
|
+
await this.semaphore.acquire();
|
|
416
|
+
try {
|
|
417
|
+
const content = messagesToText(chunk.messages);
|
|
418
|
+
const mapPrompt = renderTemplate(getPrompt(this.config, "mapPrompt"), {
|
|
419
|
+
tool: session.tool,
|
|
420
|
+
workingDir: session.workingDir,
|
|
421
|
+
startTime: formatTime(chunk.startTime),
|
|
422
|
+
endTime: formatTime(chunk.endTime),
|
|
423
|
+
chunkIndex: String(chunk.chunkIndex),
|
|
424
|
+
totalChunks: String(chunk.totalChunks),
|
|
425
|
+
content
|
|
426
|
+
});
|
|
427
|
+
const result = await this.callWithRetry([
|
|
428
|
+
{ role: "system", content: systemPrompt },
|
|
429
|
+
{ role: "user", content: mapPrompt }
|
|
430
|
+
]);
|
|
431
|
+
logger.debug({
|
|
432
|
+
sessionId: session.sessionId.slice(0, 8),
|
|
433
|
+
chunk: `${chunk.chunkIndex}/${chunk.totalChunks}`
|
|
434
|
+
}, "Map \u7247\u6BB5\u5B8C\u6210");
|
|
435
|
+
return result;
|
|
436
|
+
} catch (err) {
|
|
437
|
+
logger.error({
|
|
438
|
+
sessionId: session.sessionId.slice(0, 8),
|
|
439
|
+
chunk: `${chunk.chunkIndex}/${chunk.totalChunks}`,
|
|
440
|
+
err
|
|
441
|
+
}, "Map \u7247\u6BB5\u5931\u8D25\uFF0C\u8DF3\u8FC7");
|
|
442
|
+
return null;
|
|
443
|
+
} finally {
|
|
444
|
+
this.semaphore.release();
|
|
445
|
+
}
|
|
446
|
+
})
|
|
447
|
+
);
|
|
448
|
+
const validSummaries = mapResults.filter((s) => s !== null);
|
|
449
|
+
if (validSummaries.length === 0) {
|
|
450
|
+
return "\uFF08\u6458\u8981\u751F\u6210\u5931\u8D25\uFF09";
|
|
451
|
+
}
|
|
452
|
+
if (validSummaries.length === 1) {
|
|
453
|
+
return validSummaries[0];
|
|
454
|
+
}
|
|
455
|
+
const summariesText = validSummaries.map((s, i) => `--- \u7247\u6BB5 ${i + 1} ---
|
|
456
|
+
${s}`).join("\n\n");
|
|
457
|
+
const reducePrompt = renderTemplate(getPrompt(this.config, "reducePrompt"), {
|
|
458
|
+
summaries: summariesText
|
|
459
|
+
});
|
|
460
|
+
return this.callWithRetry([
|
|
461
|
+
{ role: "system", content: systemPrompt },
|
|
462
|
+
{ role: "user", content: reducePrompt }
|
|
463
|
+
]);
|
|
464
|
+
}
|
|
465
|
+
async callWithRetry(messages) {
|
|
466
|
+
const logger = getLogger();
|
|
467
|
+
let lastError;
|
|
468
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
469
|
+
try {
|
|
470
|
+
return await this.provider.chat(messages);
|
|
471
|
+
} catch (err) {
|
|
472
|
+
lastError = err;
|
|
473
|
+
if (attempt < MAX_RETRIES) {
|
|
474
|
+
const delay = 1e3 * (attempt + 1);
|
|
475
|
+
logger.warn({ attempt: attempt + 1, err }, `LLM \u8C03\u7528\u5931\u8D25\uFF0C${delay}ms \u540E\u91CD\u8BD5`);
|
|
476
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
throw lastError;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
// src/agent/providers/anthropic.ts
|
|
485
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
486
|
+
var AnthropicProvider = class {
|
|
487
|
+
client;
|
|
488
|
+
model;
|
|
489
|
+
maxTokens;
|
|
490
|
+
constructor(config) {
|
|
491
|
+
this.client = new Anthropic({
|
|
492
|
+
baseURL: config.baseUrl,
|
|
493
|
+
apiKey: config.apiKey
|
|
494
|
+
});
|
|
495
|
+
this.model = config.model;
|
|
496
|
+
this.maxTokens = config.maxTokens;
|
|
497
|
+
}
|
|
498
|
+
async chat(messages, options) {
|
|
499
|
+
let system;
|
|
500
|
+
const conversationMessages = [];
|
|
501
|
+
for (const msg of messages) {
|
|
502
|
+
if (msg.role === "system") {
|
|
503
|
+
system = msg.content;
|
|
504
|
+
} else {
|
|
505
|
+
conversationMessages.push({
|
|
506
|
+
role: msg.role,
|
|
507
|
+
content: msg.content
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const response = await this.client.messages.create({
|
|
512
|
+
model: this.model,
|
|
513
|
+
max_tokens: options?.maxTokens ?? this.maxTokens,
|
|
514
|
+
system,
|
|
515
|
+
messages: conversationMessages
|
|
516
|
+
});
|
|
517
|
+
const textBlock = response.content.find((b) => b.type === "text");
|
|
518
|
+
return textBlock?.type === "text" ? textBlock.text : "";
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
// src/agent/providers/openai-compatible.ts
|
|
523
|
+
import OpenAI from "openai";
|
|
524
|
+
var OpenAICompatibleProvider = class {
|
|
525
|
+
client;
|
|
526
|
+
model;
|
|
527
|
+
maxTokens;
|
|
528
|
+
constructor(config) {
|
|
529
|
+
this.client = new OpenAI({
|
|
530
|
+
baseURL: config.baseUrl,
|
|
531
|
+
apiKey: config.apiKey,
|
|
532
|
+
defaultHeaders: config.headers
|
|
533
|
+
});
|
|
534
|
+
this.model = config.model;
|
|
535
|
+
this.maxTokens = config.maxTokens;
|
|
536
|
+
}
|
|
537
|
+
async chat(messages, options) {
|
|
538
|
+
const response = await this.client.chat.completions.create({
|
|
539
|
+
model: this.model,
|
|
540
|
+
messages: messages.map((m) => ({
|
|
541
|
+
role: m.role,
|
|
542
|
+
content: m.content
|
|
543
|
+
})),
|
|
544
|
+
max_tokens: options?.maxTokens ?? this.maxTokens,
|
|
545
|
+
temperature: options?.temperature ?? 0.3
|
|
546
|
+
});
|
|
547
|
+
return response.choices[0]?.message?.content ?? "";
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// src/collector/claude-code.adapter.ts
|
|
552
|
+
import fs3 from "fs";
|
|
553
|
+
import os2 from "os";
|
|
554
|
+
import path3 from "path";
|
|
555
|
+
import readline from "readline";
|
|
556
|
+
function decodeProjectDir(encoded) {
|
|
557
|
+
const match = encoded.match(/^([A-Za-z])--(.*)?$/);
|
|
558
|
+
if (!match) return encoded;
|
|
559
|
+
const drive = match[1].toUpperCase();
|
|
560
|
+
const rest = match[2] ? match[2].replace(/-/g, "\\") : "";
|
|
561
|
+
return rest ? `${drive}:\\${rest}` : `${drive}:\\`;
|
|
562
|
+
}
|
|
563
|
+
function getClaudeDir() {
|
|
564
|
+
return path3.join(os2.homedir(), ".claude");
|
|
565
|
+
}
|
|
566
|
+
function isMessageInWindow(timestamp, since, until) {
|
|
567
|
+
return timestamp > since && timestamp <= until;
|
|
568
|
+
}
|
|
569
|
+
var ClaudeCodeAdapter = class {
|
|
570
|
+
name = "claude-code";
|
|
571
|
+
async detect() {
|
|
572
|
+
return fs3.existsSync(path3.join(getClaudeDir(), "projects"));
|
|
573
|
+
}
|
|
574
|
+
async collectSessions(since, until) {
|
|
575
|
+
const logger = getLogger();
|
|
576
|
+
const projectsDir = path3.join(getClaudeDir(), "projects");
|
|
577
|
+
const results = [];
|
|
578
|
+
const projectDirs = fs3.readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
579
|
+
for (const projectDir of projectDirs) {
|
|
580
|
+
const workingDir = decodeProjectDir(projectDir.name);
|
|
581
|
+
const projectPath = path3.join(projectsDir, projectDir.name);
|
|
582
|
+
const jsonlFiles = fs3.readdirSync(projectPath).filter((f) => f.endsWith(".jsonl"));
|
|
583
|
+
for (const file of jsonlFiles) {
|
|
584
|
+
const filePath = path3.join(projectPath, file);
|
|
585
|
+
const sessionId = path3.basename(file, ".jsonl");
|
|
586
|
+
const stat = fs3.statSync(filePath);
|
|
587
|
+
if (stat.mtime <= since) continue;
|
|
588
|
+
try {
|
|
589
|
+
const session = await this.parseSession(filePath, sessionId, workingDir, since, until);
|
|
590
|
+
if (session) {
|
|
591
|
+
results.push(session);
|
|
592
|
+
}
|
|
593
|
+
} catch (err) {
|
|
594
|
+
logger.warn({ file: filePath, err }, "Claude Code session parse failed");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return results;
|
|
599
|
+
}
|
|
600
|
+
async parseSession(filePath, sessionId, workingDir, since, until) {
|
|
601
|
+
const messages = [];
|
|
602
|
+
let cwd = workingDir;
|
|
603
|
+
const stream = fs3.createReadStream(filePath, { encoding: "utf-8" });
|
|
604
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
605
|
+
for await (const line of rl) {
|
|
606
|
+
if (!line.trim()) continue;
|
|
607
|
+
let entry;
|
|
608
|
+
try {
|
|
609
|
+
entry = JSON.parse(line);
|
|
610
|
+
} catch {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (entry.cwd) {
|
|
614
|
+
cwd = entry.cwd;
|
|
615
|
+
}
|
|
616
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
617
|
+
if (!entry.message) continue;
|
|
618
|
+
const timestamp = entry.timestamp ? new Date(entry.timestamp) : null;
|
|
619
|
+
if (!timestamp || !isMessageInWindow(timestamp, since, until)) continue;
|
|
620
|
+
const role = entry.message.role;
|
|
621
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
622
|
+
const content = extractTextContent(entry.message.content);
|
|
623
|
+
if (!content) continue;
|
|
624
|
+
messages.push({ role, content, timestamp });
|
|
625
|
+
}
|
|
626
|
+
if (messages.length === 0) return null;
|
|
627
|
+
return {
|
|
628
|
+
tool: "claude-code",
|
|
629
|
+
sessionId,
|
|
630
|
+
workingDir: cwd,
|
|
631
|
+
startTime: messages[0].timestamp,
|
|
632
|
+
endTime: messages[messages.length - 1].timestamp,
|
|
633
|
+
messages
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
function extractTextContent(content) {
|
|
638
|
+
if (typeof content === "string") return content;
|
|
639
|
+
if (Array.isArray(content)) {
|
|
640
|
+
const parts = [];
|
|
641
|
+
for (const block of content) {
|
|
642
|
+
if (!block || typeof block !== "object") continue;
|
|
643
|
+
const b = block;
|
|
644
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
645
|
+
parts.push(b.text);
|
|
646
|
+
} else if (b.type === "tool_use") {
|
|
647
|
+
const input = b.input ? JSON.stringify(b.input) : "";
|
|
648
|
+
parts.push(`[tool_use: ${b.name}] ${input}`);
|
|
649
|
+
} else if (b.type === "tool_result") {
|
|
650
|
+
const text = typeof b.content === "string" ? b.content : "";
|
|
651
|
+
parts.push(`[tool_result] ${text}`);
|
|
652
|
+
} else if (b.type === "thinking") {
|
|
653
|
+
parts.push(`[thinking] ${b.thinking || ""}`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return parts.join("\n");
|
|
657
|
+
}
|
|
658
|
+
return "";
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// src/collector/codex.adapter.ts
|
|
662
|
+
import fs4 from "fs";
|
|
663
|
+
import os3 from "os";
|
|
664
|
+
import path4 from "path";
|
|
665
|
+
import readline2 from "readline";
|
|
666
|
+
function getCodexDir() {
|
|
667
|
+
return path4.join(os3.homedir(), ".codex");
|
|
668
|
+
}
|
|
669
|
+
function isMessageInWindow2(timestamp, since, until) {
|
|
670
|
+
return timestamp > since && timestamp <= until;
|
|
671
|
+
}
|
|
672
|
+
var CodexAdapter = class {
|
|
673
|
+
name = "codex";
|
|
674
|
+
async detect() {
|
|
675
|
+
return fs4.existsSync(path4.join(getCodexDir(), "sessions"));
|
|
676
|
+
}
|
|
677
|
+
async collectSessions(since, until) {
|
|
678
|
+
const logger = getLogger();
|
|
679
|
+
const sessionsDir = path4.join(getCodexDir(), "sessions");
|
|
680
|
+
const results = [];
|
|
681
|
+
const sessionFiles = this.findSessionFiles(sessionsDir, since, until);
|
|
682
|
+
for (const filePath of sessionFiles) {
|
|
683
|
+
const sessionId = path4.basename(filePath, ".jsonl");
|
|
684
|
+
try {
|
|
685
|
+
const session = await this.parseSession(filePath, sessionId, since, until);
|
|
686
|
+
if (session) {
|
|
687
|
+
results.push(session);
|
|
688
|
+
}
|
|
689
|
+
} catch (err) {
|
|
690
|
+
logger.warn({ file: filePath, err }, "Codex session parse failed");
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return results;
|
|
694
|
+
}
|
|
695
|
+
findSessionFiles(sessionsDir, since, until) {
|
|
696
|
+
const files = [];
|
|
697
|
+
if (!fs4.existsSync(sessionsDir)) return files;
|
|
698
|
+
const years = fs4.readdirSync(sessionsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && /^\d{4}$/.test(d.name));
|
|
699
|
+
for (const yearDir of years) {
|
|
700
|
+
const yearPath = path4.join(sessionsDir, yearDir.name);
|
|
701
|
+
const months = fs4.readdirSync(yearPath, { withFileTypes: true }).filter((d) => d.isDirectory() && /^\d{2}$/.test(d.name));
|
|
702
|
+
for (const monthDir of months) {
|
|
703
|
+
const monthPath = path4.join(yearPath, monthDir.name);
|
|
704
|
+
const days = fs4.readdirSync(monthPath, { withFileTypes: true }).filter((d) => d.isDirectory() && /^\d{2}$/.test(d.name));
|
|
705
|
+
for (const dayDir of days) {
|
|
706
|
+
const dirDate = /* @__PURE__ */ new Date(`${yearDir.name}-${monthDir.name}-${dayDir.name}T00:00:00`);
|
|
707
|
+
const dirDateEnd = new Date(dirDate);
|
|
708
|
+
dirDateEnd.setDate(dirDateEnd.getDate() + 1);
|
|
709
|
+
if (dirDateEnd <= since || dirDate > until) continue;
|
|
710
|
+
const dayPath = path4.join(monthPath, dayDir.name);
|
|
711
|
+
const jsonlFiles = fs4.readdirSync(dayPath).filter((f) => f.endsWith(".jsonl")).map((f) => path4.join(dayPath, f));
|
|
712
|
+
files.push(...jsonlFiles);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return files;
|
|
717
|
+
}
|
|
718
|
+
async parseSession(filePath, sessionId, since, until) {
|
|
719
|
+
const messages = [];
|
|
720
|
+
let cwd = "";
|
|
721
|
+
const stream = fs4.createReadStream(filePath, { encoding: "utf-8" });
|
|
722
|
+
const rl = readline2.createInterface({ input: stream, crlfDelay: Infinity });
|
|
723
|
+
for await (const line of rl) {
|
|
724
|
+
if (!line.trim()) continue;
|
|
725
|
+
let entry;
|
|
726
|
+
try {
|
|
727
|
+
entry = JSON.parse(line);
|
|
728
|
+
} catch {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const type = entry.type;
|
|
732
|
+
const timestamp = entry.timestamp ? new Date(entry.timestamp) : null;
|
|
733
|
+
if (type === "session_meta") {
|
|
734
|
+
cwd = entry.payload?.cwd || "";
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (type === "turn_context" && entry.payload?.cwd) {
|
|
738
|
+
cwd = entry.payload.cwd;
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (!timestamp || !isMessageInWindow2(timestamp, since, until)) continue;
|
|
742
|
+
if (type === "event_msg" && entry.payload?.type === "user_message") {
|
|
743
|
+
const text = entry.payload.message;
|
|
744
|
+
if (typeof text === "string" && text.trim()) {
|
|
745
|
+
messages.push({ role: "user", content: text, timestamp });
|
|
746
|
+
}
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (type === "response_item") {
|
|
750
|
+
const payload = entry.payload;
|
|
751
|
+
if (!payload) continue;
|
|
752
|
+
const role = payload.role;
|
|
753
|
+
const content = extractCodexContent(payload.content);
|
|
754
|
+
if (!content) continue;
|
|
755
|
+
if (role === "developer" || role === "user") {
|
|
756
|
+
messages.push({ role: "user", content, timestamp });
|
|
757
|
+
} else if (role === "assistant") {
|
|
758
|
+
messages.push({ role: "assistant", content, timestamp });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (messages.length === 0) return null;
|
|
763
|
+
return {
|
|
764
|
+
tool: "codex",
|
|
765
|
+
sessionId,
|
|
766
|
+
workingDir: cwd,
|
|
767
|
+
startTime: messages[0].timestamp,
|
|
768
|
+
endTime: messages[messages.length - 1].timestamp,
|
|
769
|
+
messages
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
function extractCodexContent(content) {
|
|
774
|
+
if (typeof content === "string") return content;
|
|
775
|
+
if (Array.isArray(content)) {
|
|
776
|
+
const parts = [];
|
|
777
|
+
for (const block of content) {
|
|
778
|
+
if (!block || typeof block !== "object") continue;
|
|
779
|
+
const b = block;
|
|
780
|
+
if (b.type === "input_text" && typeof b.text === "string") {
|
|
781
|
+
parts.push(b.text);
|
|
782
|
+
} else if (b.type === "output_text" && typeof b.text === "string") {
|
|
783
|
+
parts.push(b.text);
|
|
784
|
+
} else if (b.type === "function_call") {
|
|
785
|
+
parts.push(`[tool_use: ${b.name}] ${b.arguments || ""}`);
|
|
786
|
+
} else if (b.type === "function_call_output") {
|
|
787
|
+
const output = typeof b.output === "string" ? b.output : "";
|
|
788
|
+
parts.push(`[tool_result] ${output}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return parts.join("\n");
|
|
792
|
+
}
|
|
793
|
+
return "";
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// src/collector/qoder.adapter.ts
|
|
797
|
+
import fs5 from "fs";
|
|
798
|
+
import os4 from "os";
|
|
799
|
+
import path5 from "path";
|
|
800
|
+
import readline3 from "readline";
|
|
801
|
+
function getQoderDir() {
|
|
802
|
+
return path5.join(os4.homedir(), ".qoder");
|
|
803
|
+
}
|
|
804
|
+
function isMessageInWindow3(timestamp, since, until) {
|
|
805
|
+
return timestamp > since && timestamp <= until;
|
|
806
|
+
}
|
|
807
|
+
var QoderAdapter = class {
|
|
808
|
+
name = "qoder";
|
|
809
|
+
async detect() {
|
|
810
|
+
return fs5.existsSync(path5.join(getQoderDir(), "projects"));
|
|
811
|
+
}
|
|
812
|
+
async collectSessions(since, until) {
|
|
813
|
+
const logger = getLogger();
|
|
814
|
+
const projectsDir = path5.join(getQoderDir(), "projects");
|
|
815
|
+
const results = [];
|
|
816
|
+
const projectDirs = fs5.readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
817
|
+
for (const projectDir of projectDirs) {
|
|
818
|
+
const projectPath = path5.join(projectsDir, projectDir.name);
|
|
819
|
+
const sessionFiles = fs5.readdirSync(projectPath).filter((f) => f.endsWith("-session.json"));
|
|
820
|
+
for (const sessionFile of sessionFiles) {
|
|
821
|
+
const metaPath = path5.join(projectPath, sessionFile);
|
|
822
|
+
try {
|
|
823
|
+
const raw = fs5.readFileSync(metaPath, "utf-8");
|
|
824
|
+
const meta = JSON.parse(raw);
|
|
825
|
+
const createdAt = new Date(meta.created_at);
|
|
826
|
+
const updatedAt = new Date(meta.updated_at);
|
|
827
|
+
if (createdAt > until || updatedAt <= since) continue;
|
|
828
|
+
const jsonlFile = path5.join(projectPath, `${meta.id}.jsonl`);
|
|
829
|
+
if (!fs5.existsSync(jsonlFile)) continue;
|
|
830
|
+
const session = await this.parseSession(jsonlFile, meta, since, until);
|
|
831
|
+
if (session) {
|
|
832
|
+
results.push(session);
|
|
833
|
+
}
|
|
834
|
+
} catch (err) {
|
|
835
|
+
logger.warn({ file: metaPath, err }, "Qoder session parse failed");
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return results;
|
|
840
|
+
}
|
|
841
|
+
async parseSession(filePath, meta, since, until) {
|
|
842
|
+
const messages = [];
|
|
843
|
+
const stream = fs5.createReadStream(filePath, { encoding: "utf-8" });
|
|
844
|
+
const rl = readline3.createInterface({ input: stream, crlfDelay: Infinity });
|
|
845
|
+
for await (const line of rl) {
|
|
846
|
+
if (!line.trim()) continue;
|
|
847
|
+
let entry;
|
|
848
|
+
try {
|
|
849
|
+
entry = JSON.parse(line);
|
|
850
|
+
} catch {
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
854
|
+
if (!entry.message) continue;
|
|
855
|
+
const timestamp = entry.timestamp ? new Date(entry.timestamp) : null;
|
|
856
|
+
if (!timestamp || !isMessageInWindow3(timestamp, since, until)) continue;
|
|
857
|
+
const role = entry.message.role;
|
|
858
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
859
|
+
const content = extractQoderContent(entry.message.content);
|
|
860
|
+
if (!content) continue;
|
|
861
|
+
messages.push({ role, content, timestamp });
|
|
862
|
+
}
|
|
863
|
+
if (messages.length === 0) return null;
|
|
864
|
+
return {
|
|
865
|
+
tool: "qoder",
|
|
866
|
+
sessionId: meta.id,
|
|
867
|
+
workingDir: meta.working_dir,
|
|
868
|
+
startTime: messages[0].timestamp,
|
|
869
|
+
endTime: messages[messages.length - 1].timestamp,
|
|
870
|
+
messages
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
function extractQoderContent(content) {
|
|
875
|
+
if (typeof content === "string") return content;
|
|
876
|
+
if (Array.isArray(content)) {
|
|
877
|
+
const parts = [];
|
|
878
|
+
for (const block of content) {
|
|
879
|
+
if (!block || typeof block !== "object") continue;
|
|
880
|
+
const b = block;
|
|
881
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
882
|
+
parts.push(b.text);
|
|
883
|
+
} else if (b.type === "tool_use") {
|
|
884
|
+
const input = b.input ? JSON.stringify(b.input) : "";
|
|
885
|
+
parts.push(`[tool_use: ${b.name}] ${input}`);
|
|
886
|
+
} else if (b.type === "tool_result") {
|
|
887
|
+
const text = typeof b.content === "string" ? b.content : "";
|
|
888
|
+
parts.push(`[tool_result] ${text}`);
|
|
889
|
+
} else if (b.type === "thinking") {
|
|
890
|
+
parts.push(`[thinking] ${b.thinking || ""}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return parts.join("\n");
|
|
894
|
+
}
|
|
895
|
+
return "";
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// src/collector/registry.ts
|
|
899
|
+
var AdapterRegistry = class {
|
|
900
|
+
adapters = /* @__PURE__ */ new Map();
|
|
901
|
+
register(adapter) {
|
|
902
|
+
this.adapters.set(adapter.name, adapter);
|
|
903
|
+
}
|
|
904
|
+
async collectAll(since, until, enabledAdapters) {
|
|
905
|
+
const logger = getLogger();
|
|
906
|
+
const results = [];
|
|
907
|
+
for (const [name, adapter] of this.adapters) {
|
|
908
|
+
if (enabledAdapters && !enabledAdapters.includes(name)) {
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
const detected = await adapter.detect();
|
|
912
|
+
if (!detected) {
|
|
913
|
+
logger.debug({ adapter: name }, "\u672A\u68C0\u6D4B\u5230\u6570\u636E\u76EE\u5F55\uFF0C\u8DF3\u8FC7");
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
try {
|
|
917
|
+
const sessions = await adapter.collectSessions(since, until);
|
|
918
|
+
logger.info({ adapter: name, count: sessions.length }, "\u91C7\u96C6\u5B8C\u6210");
|
|
919
|
+
results.push(...sessions);
|
|
920
|
+
} catch (err) {
|
|
921
|
+
logger.error({ adapter: name, err }, "\u91C7\u96C6\u5931\u8D25");
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return results;
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
// src/report/html.ts
|
|
929
|
+
import fs6 from "fs";
|
|
930
|
+
import os5 from "os";
|
|
931
|
+
import path6 from "path";
|
|
932
|
+
import { marked } from "marked";
|
|
933
|
+
var CSS = `
|
|
934
|
+
body {
|
|
935
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
936
|
+
max-width: 900px;
|
|
937
|
+
margin: 0 auto;
|
|
938
|
+
padding: 40px 20px;
|
|
939
|
+
color: #333;
|
|
940
|
+
line-height: 1.6;
|
|
941
|
+
background: #fafafa;
|
|
942
|
+
}
|
|
943
|
+
h1 {
|
|
944
|
+
color: #1a1a1a;
|
|
945
|
+
border-bottom: 2px solid #e1e4e8;
|
|
946
|
+
padding-bottom: 12px;
|
|
947
|
+
font-size: 1.8em;
|
|
948
|
+
}
|
|
949
|
+
h2 {
|
|
950
|
+
color: #24292e;
|
|
951
|
+
margin-top: 32px;
|
|
952
|
+
padding: 8px 12px;
|
|
953
|
+
background: #f0f4f8;
|
|
954
|
+
border-left: 4px solid #0366d6;
|
|
955
|
+
border-radius: 4px;
|
|
956
|
+
font-size: 1.2em;
|
|
957
|
+
font-family: "Cascadia Code", "Fira Code", Consolas, monospace;
|
|
958
|
+
}
|
|
959
|
+
h3 {
|
|
960
|
+
color: #586069;
|
|
961
|
+
font-size: 1em;
|
|
962
|
+
margin-top: 20px;
|
|
963
|
+
font-weight: 600;
|
|
964
|
+
}
|
|
965
|
+
h4 {
|
|
966
|
+
color: #333;
|
|
967
|
+
font-size: 0.95em;
|
|
968
|
+
margin-top: 16px;
|
|
969
|
+
}
|
|
970
|
+
ul {
|
|
971
|
+
padding-left: 24px;
|
|
972
|
+
}
|
|
973
|
+
li {
|
|
974
|
+
margin: 6px 0;
|
|
975
|
+
}
|
|
976
|
+
code {
|
|
977
|
+
background: #f1f3f5;
|
|
978
|
+
padding: 2px 6px;
|
|
979
|
+
border-radius: 3px;
|
|
980
|
+
font-size: 0.9em;
|
|
981
|
+
}
|
|
982
|
+
hr {
|
|
983
|
+
border: none;
|
|
984
|
+
border-top: 1px solid #e1e4e8;
|
|
985
|
+
margin: 24px 0;
|
|
986
|
+
}
|
|
987
|
+
`.trim();
|
|
988
|
+
function resolveOutputDir(config) {
|
|
989
|
+
const dir = config.output.dir.replace(/^~/, os5.homedir());
|
|
990
|
+
return path6.join(dir, "html");
|
|
991
|
+
}
|
|
992
|
+
function generateHtmlReport(date, markdownContent, config) {
|
|
993
|
+
const logger = getLogger();
|
|
994
|
+
const htmlBody = marked(markdownContent);
|
|
995
|
+
const html = `<!DOCTYPE html>
|
|
996
|
+
<html lang="zh-CN">
|
|
997
|
+
<head>
|
|
998
|
+
<meta charset="UTF-8">
|
|
999
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1000
|
+
<title>${date} \u5DE5\u4F5C\u65E5\u62A5</title>
|
|
1001
|
+
<style>${CSS}</style>
|
|
1002
|
+
</head>
|
|
1003
|
+
<body>
|
|
1004
|
+
${htmlBody}
|
|
1005
|
+
</body>
|
|
1006
|
+
</html>`;
|
|
1007
|
+
const outputDir = resolveOutputDir(config);
|
|
1008
|
+
if (!fs6.existsSync(outputDir)) {
|
|
1009
|
+
fs6.mkdirSync(outputDir, { recursive: true });
|
|
1010
|
+
}
|
|
1011
|
+
const filePath = path6.join(outputDir, `${date}.html`);
|
|
1012
|
+
fs6.writeFileSync(filePath, html, "utf-8");
|
|
1013
|
+
logger.info({ path: filePath }, "HTML report generated");
|
|
1014
|
+
return {
|
|
1015
|
+
content: html,
|
|
1016
|
+
filePath
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// src/report/markdown.ts
|
|
1021
|
+
import fs7 from "fs";
|
|
1022
|
+
import os6 from "os";
|
|
1023
|
+
import path7 from "path";
|
|
1024
|
+
function resolveOutputDir2(config) {
|
|
1025
|
+
const dir = config.output.dir.replace(/^~/, os6.homedir());
|
|
1026
|
+
return path7.join(dir, "markdown");
|
|
1027
|
+
}
|
|
1028
|
+
function formatTime2(date) {
|
|
1029
|
+
return date.toLocaleTimeString("zh-CN", {
|
|
1030
|
+
hour: "2-digit",
|
|
1031
|
+
minute: "2-digit",
|
|
1032
|
+
hour12: false
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
function createReportHeader(date) {
|
|
1036
|
+
return `# ${date} \u5DE5\u4F5C\u65E5\u62A5
|
|
1037
|
+
`;
|
|
1038
|
+
}
|
|
1039
|
+
function renderBatchFragment(summaries) {
|
|
1040
|
+
const sorted = [...summaries].sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
|
1041
|
+
const batchStart = sorted[0].startTime;
|
|
1042
|
+
const batchEnd = sorted[sorted.length - 1].endTime;
|
|
1043
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1044
|
+
for (const summary of sorted) {
|
|
1045
|
+
const list = groups.get(summary.workingDir) || [];
|
|
1046
|
+
list.push(summary);
|
|
1047
|
+
groups.set(summary.workingDir, list);
|
|
1048
|
+
}
|
|
1049
|
+
const sections = [
|
|
1050
|
+
`## ${formatTime2(batchStart)}~${formatTime2(batchEnd)}`,
|
|
1051
|
+
...Array.from(groups.entries()).flatMap(([workingDir, items]) => {
|
|
1052
|
+
const rendered = [`### ${workingDir}`];
|
|
1053
|
+
for (const item of items) {
|
|
1054
|
+
rendered.push(`#### [${item.tool}] ${formatTime2(item.startTime)}~${formatTime2(item.endTime)}`);
|
|
1055
|
+
rendered.push(item.summary.trim());
|
|
1056
|
+
}
|
|
1057
|
+
return rendered;
|
|
1058
|
+
})
|
|
1059
|
+
];
|
|
1060
|
+
return `${sections.join("\n\n")}
|
|
1061
|
+
`;
|
|
1062
|
+
}
|
|
1063
|
+
async function generateMarkdownReport(date, summaries, _provider, config, options) {
|
|
1064
|
+
const logger = getLogger();
|
|
1065
|
+
const outputDir = resolveOutputDir2(config);
|
|
1066
|
+
if (!fs7.existsSync(outputDir)) {
|
|
1067
|
+
fs7.mkdirSync(outputDir, { recursive: true });
|
|
1068
|
+
}
|
|
1069
|
+
const filePath = path7.join(outputDir, `${date}.md`);
|
|
1070
|
+
const fragment = renderBatchFragment(summaries);
|
|
1071
|
+
if (!options.append || !fs7.existsSync(filePath)) {
|
|
1072
|
+
const content2 = `${createReportHeader(date)}
|
|
1073
|
+
${fragment}`.trimEnd() + "\n";
|
|
1074
|
+
fs7.writeFileSync(filePath, content2, "utf-8");
|
|
1075
|
+
} else {
|
|
1076
|
+
const prefix = fs7.statSync(filePath).size > 0 ? "\n" : "";
|
|
1077
|
+
fs7.appendFileSync(filePath, `${prefix}${fragment}`, "utf-8");
|
|
1078
|
+
}
|
|
1079
|
+
const content = fs7.readFileSync(filePath, "utf-8");
|
|
1080
|
+
logger.info({ path: filePath, append: options.append }, "Markdown report updated");
|
|
1081
|
+
return {
|
|
1082
|
+
content,
|
|
1083
|
+
filePath
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// src/store/state.ts
|
|
1088
|
+
import fs8 from "fs";
|
|
1089
|
+
import path8 from "path";
|
|
1090
|
+
import Database from "better-sqlite3";
|
|
1091
|
+
var _db;
|
|
1092
|
+
function getDb() {
|
|
1093
|
+
if (_db) return _db;
|
|
1094
|
+
const dbDir = getSynthDir();
|
|
1095
|
+
if (!fs8.existsSync(dbDir)) {
|
|
1096
|
+
fs8.mkdirSync(dbDir, { recursive: true });
|
|
1097
|
+
}
|
|
1098
|
+
const dbPath = path8.join(dbDir, "state.sqlite");
|
|
1099
|
+
_db = new Database(dbPath);
|
|
1100
|
+
_db.exec(`
|
|
1101
|
+
CREATE TABLE IF NOT EXISTS kv (
|
|
1102
|
+
key TEXT PRIMARY KEY,
|
|
1103
|
+
value TEXT NOT NULL
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
CREATE TABLE IF NOT EXISTS message_fingerprints (
|
|
1107
|
+
date TEXT NOT NULL,
|
|
1108
|
+
fingerprint TEXT NOT NULL,
|
|
1109
|
+
created_at TEXT NOT NULL,
|
|
1110
|
+
PRIMARY KEY (date, fingerprint)
|
|
1111
|
+
);
|
|
1112
|
+
`);
|
|
1113
|
+
return _db;
|
|
1114
|
+
}
|
|
1115
|
+
function getValue(key) {
|
|
1116
|
+
const db = getDb();
|
|
1117
|
+
const row = db.prepare("SELECT value FROM kv WHERE key = ?").get(key);
|
|
1118
|
+
return row?.value ?? null;
|
|
1119
|
+
}
|
|
1120
|
+
function setValue(key, value) {
|
|
1121
|
+
const db = getDb();
|
|
1122
|
+
db.prepare(
|
|
1123
|
+
"INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
1124
|
+
).run(key, value);
|
|
1125
|
+
}
|
|
1126
|
+
function getDailyCursorKey(date) {
|
|
1127
|
+
return `dailyCursor:${date}`;
|
|
1128
|
+
}
|
|
1129
|
+
function getDailyCursor(date) {
|
|
1130
|
+
const value = getValue(getDailyCursorKey(date));
|
|
1131
|
+
return value ? new Date(value) : null;
|
|
1132
|
+
}
|
|
1133
|
+
function setDailyCursor(date, cursor) {
|
|
1134
|
+
setValue(getDailyCursorKey(date), cursor.toISOString());
|
|
1135
|
+
}
|
|
1136
|
+
function hasMessageFingerprint(date, fingerprint) {
|
|
1137
|
+
const db = getDb();
|
|
1138
|
+
const row = db.prepare("SELECT 1 FROM message_fingerprints WHERE date = ? AND fingerprint = ?").get(date, fingerprint);
|
|
1139
|
+
return !!row;
|
|
1140
|
+
}
|
|
1141
|
+
function saveMessageFingerprints(date, fingerprints) {
|
|
1142
|
+
if (fingerprints.length === 0) return;
|
|
1143
|
+
const db = getDb();
|
|
1144
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1145
|
+
const stmt = db.prepare(
|
|
1146
|
+
"INSERT OR IGNORE INTO message_fingerprints (date, fingerprint, created_at) VALUES (?, ?, ?)"
|
|
1147
|
+
);
|
|
1148
|
+
const tx = db.transaction((items) => {
|
|
1149
|
+
for (const fingerprint of items) {
|
|
1150
|
+
stmt.run(date, fingerprint, now);
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
tx(fingerprints);
|
|
1154
|
+
}
|
|
1155
|
+
function pruneMessageFingerprints(beforeDate) {
|
|
1156
|
+
const db = getDb();
|
|
1157
|
+
db.prepare("DELETE FROM message_fingerprints WHERE date < ?").run(beforeDate);
|
|
1158
|
+
}
|
|
1159
|
+
function clearCollectionState() {
|
|
1160
|
+
const db = getDb();
|
|
1161
|
+
db.exec(`
|
|
1162
|
+
DELETE FROM kv WHERE key = 'lastRunAt' OR key LIKE 'dailyCursor:%';
|
|
1163
|
+
DELETE FROM message_fingerprints;
|
|
1164
|
+
`);
|
|
1165
|
+
}
|
|
1166
|
+
function closeDb() {
|
|
1167
|
+
if (_db) {
|
|
1168
|
+
_db.close();
|
|
1169
|
+
_db = void 0;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// src/runner.ts
|
|
1174
|
+
var SAFETY_LAG_MS = 90 * 1e3;
|
|
1175
|
+
var LOOKBACK_MS = 2 * 60 * 1e3;
|
|
1176
|
+
function createProvider(config) {
|
|
1177
|
+
if (config.llm.provider === "anthropic") {
|
|
1178
|
+
return new AnthropicProvider({
|
|
1179
|
+
baseUrl: config.llm.baseUrl,
|
|
1180
|
+
apiKey: config.llm.apiKey,
|
|
1181
|
+
model: config.llm.model,
|
|
1182
|
+
maxTokens: config.llm.maxTokens
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
return new OpenAICompatibleProvider({
|
|
1186
|
+
baseUrl: config.llm.baseUrl,
|
|
1187
|
+
apiKey: config.llm.apiKey,
|
|
1188
|
+
model: config.llm.model,
|
|
1189
|
+
maxTokens: config.llm.maxTokens,
|
|
1190
|
+
headers: config.llm.headers
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
function createRegistry(config) {
|
|
1194
|
+
const registry = new AdapterRegistry();
|
|
1195
|
+
const adapters = {
|
|
1196
|
+
"claude-code": () => new ClaudeCodeAdapter(),
|
|
1197
|
+
codex: () => new CodexAdapter(),
|
|
1198
|
+
qoder: () => new QoderAdapter()
|
|
1199
|
+
};
|
|
1200
|
+
for (const name of config.collector.adapters) {
|
|
1201
|
+
const factory = adapters[name];
|
|
1202
|
+
if (factory) {
|
|
1203
|
+
registry.register(factory());
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return registry;
|
|
1207
|
+
}
|
|
1208
|
+
function formatDate(date) {
|
|
1209
|
+
const y = date.getFullYear();
|
|
1210
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
1211
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
1212
|
+
return `${y}-${m}-${d}`;
|
|
1213
|
+
}
|
|
1214
|
+
function startOfDay(date) {
|
|
1215
|
+
const value = new Date(date);
|
|
1216
|
+
value.setHours(0, 0, 0, 0);
|
|
1217
|
+
return value;
|
|
1218
|
+
}
|
|
1219
|
+
function endOfDay(date) {
|
|
1220
|
+
const value = new Date(date);
|
|
1221
|
+
value.setHours(23, 59, 59, 999);
|
|
1222
|
+
return value;
|
|
1223
|
+
}
|
|
1224
|
+
function maxDate(left, right) {
|
|
1225
|
+
return left > right ? left : right;
|
|
1226
|
+
}
|
|
1227
|
+
function getSafeUntil(now) {
|
|
1228
|
+
return new Date(now.getTime() - SAFETY_LAG_MS);
|
|
1229
|
+
}
|
|
1230
|
+
function fingerprintMessage(session, message) {
|
|
1231
|
+
const digest = createHash("sha1").update(session.tool).update("\n").update(session.sessionId).update("\n").update(message.timestamp.toISOString()).update("\n").update(message.role).update("\n").update(message.content).digest("hex");
|
|
1232
|
+
return `${session.tool}:${session.sessionId}:${digest}`;
|
|
1233
|
+
}
|
|
1234
|
+
function filterDuplicateMessages(date, sessions) {
|
|
1235
|
+
const filtered = [];
|
|
1236
|
+
for (const session of sessions) {
|
|
1237
|
+
const messages = session.messages.filter((message) => !hasMessageFingerprint(date, fingerprintMessage(session, message)));
|
|
1238
|
+
if (messages.length === 0) continue;
|
|
1239
|
+
filtered.push({
|
|
1240
|
+
...session,
|
|
1241
|
+
startTime: messages[0].timestamp,
|
|
1242
|
+
endTime: messages[messages.length - 1].timestamp,
|
|
1243
|
+
messages
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
return filtered;
|
|
1247
|
+
}
|
|
1248
|
+
function collectSessionFingerprints(session) {
|
|
1249
|
+
return session.messages.map((message) => fingerprintMessage(session, message));
|
|
1250
|
+
}
|
|
1251
|
+
function summarizeSessionsConfig(config, sessions, provider) {
|
|
1252
|
+
const logger = getLogger();
|
|
1253
|
+
const summarizer = new Summarizer(provider, config);
|
|
1254
|
+
return (async () => {
|
|
1255
|
+
const summaries = [];
|
|
1256
|
+
const fingerprints = [];
|
|
1257
|
+
let hadFailures = false;
|
|
1258
|
+
for (const session of sessions) {
|
|
1259
|
+
try {
|
|
1260
|
+
const summary = await summarizer.summarizeSession(session);
|
|
1261
|
+
summaries.push(summary);
|
|
1262
|
+
fingerprints.push(...collectSessionFingerprints(session));
|
|
1263
|
+
logger.info(
|
|
1264
|
+
{
|
|
1265
|
+
tool: session.tool,
|
|
1266
|
+
workingDir: session.workingDir,
|
|
1267
|
+
sessionId: session.sessionId.slice(0, 8),
|
|
1268
|
+
startTime: session.startTime.toISOString(),
|
|
1269
|
+
endTime: session.endTime.toISOString(),
|
|
1270
|
+
messages: session.messages.length
|
|
1271
|
+
},
|
|
1272
|
+
"Session summarized"
|
|
1273
|
+
);
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
hadFailures = true;
|
|
1276
|
+
logger.error(
|
|
1277
|
+
{
|
|
1278
|
+
sessionId: session.sessionId.slice(0, 8),
|
|
1279
|
+
err
|
|
1280
|
+
},
|
|
1281
|
+
"Session summarize failed and was skipped"
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
return { summaries, fingerprints, hadFailures };
|
|
1286
|
+
})();
|
|
1287
|
+
}
|
|
1288
|
+
function resolveOutputBaseDir(config) {
|
|
1289
|
+
return config.output.dir.replace(/^~/, os7.homedir());
|
|
1290
|
+
}
|
|
1291
|
+
function clearHistoricalReports(config) {
|
|
1292
|
+
const logger = getLogger();
|
|
1293
|
+
const baseDir = resolveOutputBaseDir(config);
|
|
1294
|
+
const targets = [
|
|
1295
|
+
{ dir: path9.join(baseDir, "markdown"), ext: ".md" },
|
|
1296
|
+
{ dir: path9.join(baseDir, "html"), ext: ".html" }
|
|
1297
|
+
];
|
|
1298
|
+
for (const target of targets) {
|
|
1299
|
+
if (!fs9.existsSync(target.dir)) continue;
|
|
1300
|
+
for (const file of fs9.readdirSync(target.dir)) {
|
|
1301
|
+
if (!file.endsWith(target.ext)) continue;
|
|
1302
|
+
fs9.unlinkSync(path9.join(target.dir, file));
|
|
1303
|
+
}
|
|
1304
|
+
logger.info({ dir: target.dir }, "Historical report directory cleared");
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
function partitionSessionsByDate(sessions) {
|
|
1308
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1309
|
+
for (const session of sessions) {
|
|
1310
|
+
const dateBuckets = /* @__PURE__ */ new Map();
|
|
1311
|
+
for (const message of session.messages) {
|
|
1312
|
+
const dateKey = formatDate(message.timestamp);
|
|
1313
|
+
const list = dateBuckets.get(dateKey) || [];
|
|
1314
|
+
list.push(message);
|
|
1315
|
+
dateBuckets.set(dateKey, list);
|
|
1316
|
+
}
|
|
1317
|
+
for (const [dateKey, messages] of dateBuckets.entries()) {
|
|
1318
|
+
const sessionsForDate = groups.get(dateKey) || [];
|
|
1319
|
+
sessionsForDate.push({
|
|
1320
|
+
...session,
|
|
1321
|
+
sessionId: `${session.sessionId}:${dateKey}`,
|
|
1322
|
+
startTime: messages[0].timestamp,
|
|
1323
|
+
endTime: messages[messages.length - 1].timestamp,
|
|
1324
|
+
messages
|
|
1325
|
+
});
|
|
1326
|
+
groups.set(dateKey, sessionsForDate);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
return groups;
|
|
1330
|
+
}
|
|
1331
|
+
async function renderReportsForDate(config, date, summaries, provider, append) {
|
|
1332
|
+
const formats = config.output.formats;
|
|
1333
|
+
if (!formats.includes("markdown") && !formats.includes("html")) {
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
const markdownReport = await generateMarkdownReport(date, summaries, provider, config, {
|
|
1337
|
+
append
|
|
1338
|
+
});
|
|
1339
|
+
if (formats.includes("html")) {
|
|
1340
|
+
generateHtmlReport(date, markdownReport.content, config);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
async function run(config, options = {}) {
|
|
1344
|
+
const logger = getLogger();
|
|
1345
|
+
if (!config.llm.apiKey) {
|
|
1346
|
+
logger.error("Missing LLM apiKey. Run `synth config --init` and update ~/.synth/config.yaml");
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
const now = /* @__PURE__ */ new Date();
|
|
1350
|
+
let since;
|
|
1351
|
+
let until;
|
|
1352
|
+
let rawSince;
|
|
1353
|
+
let dateStr;
|
|
1354
|
+
let appendReport = true;
|
|
1355
|
+
let updateCursor = true;
|
|
1356
|
+
let enableFingerprintDedup = false;
|
|
1357
|
+
if (options.date) {
|
|
1358
|
+
const targetDay = /* @__PURE__ */ new Date(`${options.date}T00:00:00`);
|
|
1359
|
+
since = startOfDay(targetDay);
|
|
1360
|
+
until = endOfDay(targetDay);
|
|
1361
|
+
rawSince = since;
|
|
1362
|
+
dateStr = options.date;
|
|
1363
|
+
appendReport = false;
|
|
1364
|
+
updateCursor = false;
|
|
1365
|
+
} else if (options.since) {
|
|
1366
|
+
rawSince = options.since;
|
|
1367
|
+
since = options.since;
|
|
1368
|
+
until = getSafeUntil(now);
|
|
1369
|
+
dateStr = formatDate(until);
|
|
1370
|
+
appendReport = true;
|
|
1371
|
+
updateCursor = false;
|
|
1372
|
+
} else {
|
|
1373
|
+
dateStr = formatDate(now);
|
|
1374
|
+
const dayStart = startOfDay(now);
|
|
1375
|
+
const cursor = getDailyCursor(dateStr);
|
|
1376
|
+
rawSince = cursor && cursor > dayStart ? cursor : dayStart;
|
|
1377
|
+
since = maxDate(dayStart, new Date(rawSince.getTime() - LOOKBACK_MS));
|
|
1378
|
+
until = getSafeUntil(now);
|
|
1379
|
+
enableFingerprintDedup = true;
|
|
1380
|
+
}
|
|
1381
|
+
if (formatDate(until) !== dateStr) {
|
|
1382
|
+
until = dateStr === formatDate(now) ? maxDate(startOfDay(now), until) : until;
|
|
1383
|
+
}
|
|
1384
|
+
logger.info(
|
|
1385
|
+
{
|
|
1386
|
+
rawSince: rawSince.toISOString(),
|
|
1387
|
+
since: since.toISOString(),
|
|
1388
|
+
until: until.toISOString(),
|
|
1389
|
+
date: dateStr,
|
|
1390
|
+
appendReport,
|
|
1391
|
+
updateCursor,
|
|
1392
|
+
enableFingerprintDedup,
|
|
1393
|
+
safetyLagMs: options.date ? 0 : SAFETY_LAG_MS,
|
|
1394
|
+
lookbackMs: enableFingerprintDedup ? LOOKBACK_MS : 0
|
|
1395
|
+
},
|
|
1396
|
+
"Run started"
|
|
1397
|
+
);
|
|
1398
|
+
if (until <= since) {
|
|
1399
|
+
logger.info({ since: since.toISOString(), until: until.toISOString(), date: dateStr }, "No new collection window");
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (enableFingerprintDedup) {
|
|
1403
|
+
const keepFrom = new Date(startOfDay(now));
|
|
1404
|
+
keepFrom.setDate(keepFrom.getDate() - 2);
|
|
1405
|
+
pruneMessageFingerprints(formatDate(keepFrom));
|
|
1406
|
+
}
|
|
1407
|
+
const registry = createRegistry(config);
|
|
1408
|
+
const collectedSessions = await registry.collectAll(since, until, config.collector.adapters);
|
|
1409
|
+
const sessions = enableFingerprintDedup ? filterDuplicateMessages(dateStr, collectedSessions) : collectedSessions;
|
|
1410
|
+
if (sessions.length === 0) {
|
|
1411
|
+
logger.info("No new messages collected in the requested window");
|
|
1412
|
+
if (updateCursor) {
|
|
1413
|
+
setDailyCursor(dateStr, until);
|
|
1414
|
+
}
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
logger.info({ count: sessions.length }, "Collection finished");
|
|
1418
|
+
const provider = createProvider(config);
|
|
1419
|
+
const { summaries, fingerprints, hadFailures } = await summarizeSessionsConfig(config, sessions, provider);
|
|
1420
|
+
if (summaries.length === 0) {
|
|
1421
|
+
logger.warn("All sessions failed during summarization");
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
await renderReportsForDate(config, dateStr, summaries, provider, appendReport);
|
|
1425
|
+
if (enableFingerprintDedup && fingerprints.length > 0) {
|
|
1426
|
+
saveMessageFingerprints(dateStr, fingerprints);
|
|
1427
|
+
logger.info({ date: dateStr, fingerprints: fingerprints.length }, "Message fingerprints saved");
|
|
1428
|
+
}
|
|
1429
|
+
if (updateCursor && !hadFailures) {
|
|
1430
|
+
setDailyCursor(dateStr, until);
|
|
1431
|
+
logger.info({ date: dateStr, cursor: until.toISOString() }, "Daily cursor updated");
|
|
1432
|
+
} else if (updateCursor && hadFailures) {
|
|
1433
|
+
logger.warn({ date: dateStr }, "Cursor not advanced because some sessions failed; next run will retry with dedup");
|
|
1434
|
+
}
|
|
1435
|
+
logger.info({ date: dateStr, sessions: summaries.length }, "Run finished");
|
|
1436
|
+
}
|
|
1437
|
+
async function backfillHistory(config) {
|
|
1438
|
+
const logger = getLogger();
|
|
1439
|
+
if (!config.llm.apiKey) {
|
|
1440
|
+
logger.error("Missing LLM apiKey. Run `synth config --init` and update ~/.synth/config.yaml");
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
const now = /* @__PURE__ */ new Date();
|
|
1444
|
+
const safeUntil = getSafeUntil(now);
|
|
1445
|
+
const today = formatDate(safeUntil);
|
|
1446
|
+
logger.info({ until: safeUntil.toISOString() }, "History backfill started");
|
|
1447
|
+
clearCollectionState();
|
|
1448
|
+
clearHistoricalReports(config);
|
|
1449
|
+
const registry = createRegistry(config);
|
|
1450
|
+
const provider = createProvider(config);
|
|
1451
|
+
const sessions = await registry.collectAll(/* @__PURE__ */ new Date(0), safeUntil, config.collector.adapters);
|
|
1452
|
+
if (sessions.length === 0) {
|
|
1453
|
+
logger.info("No sessions found for historical backfill");
|
|
1454
|
+
setDailyCursor(today, safeUntil);
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
const sessionsByDate = partitionSessionsByDate(sessions);
|
|
1458
|
+
const sortedDates = Array.from(sessionsByDate.keys()).sort();
|
|
1459
|
+
const todayFingerprints = [];
|
|
1460
|
+
for (const date of sortedDates) {
|
|
1461
|
+
const dateSessions = sessionsByDate.get(date) || [];
|
|
1462
|
+
const { summaries, fingerprints } = await summarizeSessionsConfig(config, dateSessions, provider);
|
|
1463
|
+
if (summaries.length === 0) {
|
|
1464
|
+
logger.warn({ date }, "No summaries generated for historical date");
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
await renderReportsForDate(config, date, summaries, provider, false);
|
|
1468
|
+
if (date === today) {
|
|
1469
|
+
todayFingerprints.push(...fingerprints);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
if (todayFingerprints.length > 0) {
|
|
1473
|
+
saveMessageFingerprints(today, todayFingerprints);
|
|
1474
|
+
}
|
|
1475
|
+
setDailyCursor(today, safeUntil);
|
|
1476
|
+
logger.info({ dates: sortedDates.length, until: safeUntil.toISOString() }, "History backfill finished");
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// src/service.ts
|
|
1480
|
+
import fs10 from "fs";
|
|
1481
|
+
import path10 from "path";
|
|
1482
|
+
import { execFile } from "child_process";
|
|
1483
|
+
import { promisify } from "util";
|
|
1484
|
+
import { createRequire } from "module";
|
|
1485
|
+
import { fileURLToPath } from "url";
|
|
1486
|
+
|
|
1487
|
+
// src/scheduler.ts
|
|
1488
|
+
import cron from "node-cron";
|
|
1489
|
+
function formatNextRun(task) {
|
|
1490
|
+
const nextRun = task.getNextRun();
|
|
1491
|
+
return nextRun ? nextRun.toISOString() : null;
|
|
1492
|
+
}
|
|
1493
|
+
function startScheduler(config) {
|
|
1494
|
+
const logger = getLogger();
|
|
1495
|
+
const expression = config.schedule.cron;
|
|
1496
|
+
if (!cron.validate(expression)) {
|
|
1497
|
+
throw new Error(`Invalid cron expression: ${expression}`);
|
|
1498
|
+
}
|
|
1499
|
+
const task = cron.schedule(
|
|
1500
|
+
expression,
|
|
1501
|
+
async () => {
|
|
1502
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
1503
|
+
logger.info(
|
|
1504
|
+
{ cron: expression, startedAt: startedAt.toISOString() },
|
|
1505
|
+
"Scheduled run started"
|
|
1506
|
+
);
|
|
1507
|
+
try {
|
|
1508
|
+
await run(config);
|
|
1509
|
+
logger.info(
|
|
1510
|
+
{ cron: expression, finishedAt: (/* @__PURE__ */ new Date()).toISOString(), nextRun: formatNextRun(task) },
|
|
1511
|
+
"Scheduled run finished"
|
|
1512
|
+
);
|
|
1513
|
+
} catch (err) {
|
|
1514
|
+
logger.error(
|
|
1515
|
+
{
|
|
1516
|
+
cron: expression,
|
|
1517
|
+
err,
|
|
1518
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1519
|
+
nextRun: formatNextRun(task)
|
|
1520
|
+
},
|
|
1521
|
+
"Scheduled run failed"
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
name: "synth-scheduler",
|
|
1527
|
+
noOverlap: true
|
|
1528
|
+
}
|
|
1529
|
+
);
|
|
1530
|
+
task.on("execution:overlap", () => {
|
|
1531
|
+
logger.warn({ cron: expression }, "Scheduled run skipped because previous execution is still running");
|
|
1532
|
+
});
|
|
1533
|
+
task.on("execution:missed", (context) => {
|
|
1534
|
+
logger.warn(
|
|
1535
|
+
{ cron: expression, scheduledAt: context.date.toISOString(), triggeredAt: context.triggeredAt.toISOString() },
|
|
1536
|
+
"Scheduled run was missed"
|
|
1537
|
+
);
|
|
1538
|
+
});
|
|
1539
|
+
task.on("execution:failed", (context) => {
|
|
1540
|
+
logger.error(
|
|
1541
|
+
{
|
|
1542
|
+
cron: expression,
|
|
1543
|
+
scheduledAt: context.date.toISOString(),
|
|
1544
|
+
triggeredAt: context.triggeredAt.toISOString(),
|
|
1545
|
+
err: context.execution?.error
|
|
1546
|
+
},
|
|
1547
|
+
"Scheduled execution callback failed"
|
|
1548
|
+
);
|
|
1549
|
+
});
|
|
1550
|
+
logger.info({ cron: expression, nextRun: formatNextRun(task) }, "Scheduler started");
|
|
1551
|
+
return task;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// src/service.ts
|
|
1555
|
+
var execFileAsync = promisify(execFile);
|
|
1556
|
+
var require2 = createRequire(import.meta.url);
|
|
1557
|
+
var nodeWindows = require2("node-windows");
|
|
1558
|
+
var SERVICE_NAME = "Synth AI Work Summary Service";
|
|
1559
|
+
var SERVICE_DESCRIPTION = "Collect AI coding sessions and generate scheduled work summaries.";
|
|
1560
|
+
var SERVICE_ID = SERVICE_NAME.replace(/[^\w]/g, "").toLowerCase();
|
|
1561
|
+
function ensureWindows() {
|
|
1562
|
+
if (process.platform !== "win32") {
|
|
1563
|
+
throw new Error("Windows service management is only supported on win32");
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
function ensureDir(dir) {
|
|
1567
|
+
if (!fs10.existsSync(dir)) {
|
|
1568
|
+
fs10.mkdirSync(dir, { recursive: true });
|
|
1569
|
+
}
|
|
1570
|
+
return dir;
|
|
1571
|
+
}
|
|
1572
|
+
function getServiceScriptPath() {
|
|
1573
|
+
const currentFile = fileURLToPath(new URL("./index.js", import.meta.url));
|
|
1574
|
+
if (fs10.existsSync(currentFile)) {
|
|
1575
|
+
return currentFile;
|
|
1576
|
+
}
|
|
1577
|
+
const distFallback = path10.resolve(process.cwd(), "dist", "index.js");
|
|
1578
|
+
if (fs10.existsSync(distFallback)) {
|
|
1579
|
+
return distFallback;
|
|
1580
|
+
}
|
|
1581
|
+
throw new Error("Cannot locate dist/index.js. Run `npm run build` before installing the service.");
|
|
1582
|
+
}
|
|
1583
|
+
function getServiceWorkingDirectory(scriptPath) {
|
|
1584
|
+
const dir = path10.dirname(scriptPath);
|
|
1585
|
+
return path10.basename(dir).toLowerCase() === "dist" ? path10.resolve(dir, "..") : dir;
|
|
1586
|
+
}
|
|
1587
|
+
function getServiceLogDir() {
|
|
1588
|
+
return ensureDir(path10.join(getSynthDir(), "logs", "service"));
|
|
1589
|
+
}
|
|
1590
|
+
function createService() {
|
|
1591
|
+
const script = getServiceScriptPath();
|
|
1592
|
+
return new nodeWindows.Service({
|
|
1593
|
+
name: SERVICE_NAME,
|
|
1594
|
+
description: SERVICE_DESCRIPTION,
|
|
1595
|
+
script,
|
|
1596
|
+
scriptOptions: "service-run",
|
|
1597
|
+
workingDirectory: getServiceWorkingDirectory(script),
|
|
1598
|
+
logpath: getServiceLogDir(),
|
|
1599
|
+
maxRestarts: 5,
|
|
1600
|
+
wait: 2,
|
|
1601
|
+
grow: 0.25,
|
|
1602
|
+
stopparentfirst: true,
|
|
1603
|
+
env: {
|
|
1604
|
+
name: "SYNTH_SERVICE",
|
|
1605
|
+
value: "1"
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
function waitForEvent(service, action, successEvents, failureEvents, mapResult) {
|
|
1610
|
+
return new Promise((resolve, reject) => {
|
|
1611
|
+
const handlers = {};
|
|
1612
|
+
const cleanup = () => {
|
|
1613
|
+
for (const eventName of [...successEvents, ...failureEvents]) {
|
|
1614
|
+
const handler = handlers[eventName];
|
|
1615
|
+
if (handler) {
|
|
1616
|
+
service.removeListener(eventName, handler);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
for (const eventName of successEvents) {
|
|
1621
|
+
handlers[eventName] = () => {
|
|
1622
|
+
cleanup();
|
|
1623
|
+
resolve(mapResult(eventName));
|
|
1624
|
+
};
|
|
1625
|
+
service.on(eventName, handlers[eventName]);
|
|
1626
|
+
}
|
|
1627
|
+
for (const eventName of failureEvents) {
|
|
1628
|
+
handlers[eventName] = (...args) => {
|
|
1629
|
+
cleanup();
|
|
1630
|
+
const error = args[0] instanceof Error ? args[0] : new Error(String(args[0] ?? eventName));
|
|
1631
|
+
reject(error);
|
|
1632
|
+
};
|
|
1633
|
+
service.on(eventName, handlers[eventName]);
|
|
1634
|
+
}
|
|
1635
|
+
try {
|
|
1636
|
+
action();
|
|
1637
|
+
} catch (err) {
|
|
1638
|
+
cleanup();
|
|
1639
|
+
reject(err);
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
function normalizeState(rawState) {
|
|
1644
|
+
switch (rawState) {
|
|
1645
|
+
case "RUNNING":
|
|
1646
|
+
return "running";
|
|
1647
|
+
case "STOPPED":
|
|
1648
|
+
return "stopped";
|
|
1649
|
+
case "START_PENDING":
|
|
1650
|
+
return "start-pending";
|
|
1651
|
+
case "STOP_PENDING":
|
|
1652
|
+
return "stop-pending";
|
|
1653
|
+
case "PAUSED":
|
|
1654
|
+
return "paused";
|
|
1655
|
+
default:
|
|
1656
|
+
return rawState ? "unknown" : "not-installed";
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
async function getServiceStatus() {
|
|
1660
|
+
ensureWindows();
|
|
1661
|
+
try {
|
|
1662
|
+
const { stdout, stderr } = await execFileAsync("sc.exe", ["query", SERVICE_ID], {
|
|
1663
|
+
windowsHide: true
|
|
1664
|
+
});
|
|
1665
|
+
const output = `${stdout}
|
|
1666
|
+
${stderr}`;
|
|
1667
|
+
const match = output.match(/STATE\s*:\s*\d+\s+([A-Z_]+)/);
|
|
1668
|
+
const rawState = match?.[1];
|
|
1669
|
+
return {
|
|
1670
|
+
installed: true,
|
|
1671
|
+
state: normalizeState(rawState),
|
|
1672
|
+
rawState
|
|
1673
|
+
};
|
|
1674
|
+
} catch (err) {
|
|
1675
|
+
const code = typeof err === "object" && err !== null && "code" in err ? err.code : void 0;
|
|
1676
|
+
const stdout = typeof err === "object" && err !== null && "stdout" in err ? String(err.stdout ?? "") : "";
|
|
1677
|
+
const stderr = typeof err === "object" && err !== null && "stderr" in err ? String(err.stderr ?? "") : "";
|
|
1678
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1679
|
+
if (code === 1060 || stdout.includes("1060") || stderr.includes("1060") || message.includes("does not exist")) {
|
|
1680
|
+
return { installed: false, state: "not-installed" };
|
|
1681
|
+
}
|
|
1682
|
+
throw err;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
async function installService() {
|
|
1686
|
+
ensureWindows();
|
|
1687
|
+
const status = await getServiceStatus();
|
|
1688
|
+
if (status.installed) {
|
|
1689
|
+
if (status.state !== "running" && status.state !== "start-pending") {
|
|
1690
|
+
await startService();
|
|
1691
|
+
return getServiceStatus();
|
|
1692
|
+
}
|
|
1693
|
+
return status;
|
|
1694
|
+
}
|
|
1695
|
+
const service = createService();
|
|
1696
|
+
const result = await waitForEvent(
|
|
1697
|
+
service,
|
|
1698
|
+
() => service.install(),
|
|
1699
|
+
["install", "alreadyinstalled"],
|
|
1700
|
+
["error", "invalidinstallation"],
|
|
1701
|
+
(eventName) => eventName
|
|
1702
|
+
);
|
|
1703
|
+
if (result === "install") {
|
|
1704
|
+
await startService();
|
|
1705
|
+
}
|
|
1706
|
+
return getServiceStatus();
|
|
1707
|
+
}
|
|
1708
|
+
async function uninstallService() {
|
|
1709
|
+
ensureWindows();
|
|
1710
|
+
const status = await getServiceStatus();
|
|
1711
|
+
if (!status.installed) {
|
|
1712
|
+
return status;
|
|
1713
|
+
}
|
|
1714
|
+
const service = createService();
|
|
1715
|
+
await waitForEvent(
|
|
1716
|
+
service,
|
|
1717
|
+
() => service.uninstall(),
|
|
1718
|
+
["uninstall", "alreadyuninstalled"],
|
|
1719
|
+
["error"],
|
|
1720
|
+
() => void 0
|
|
1721
|
+
);
|
|
1722
|
+
return getServiceStatus();
|
|
1723
|
+
}
|
|
1724
|
+
async function startService() {
|
|
1725
|
+
ensureWindows();
|
|
1726
|
+
const status = await getServiceStatus();
|
|
1727
|
+
if (!status.installed || status.state === "running" || status.state === "start-pending") {
|
|
1728
|
+
return status;
|
|
1729
|
+
}
|
|
1730
|
+
const service = createService();
|
|
1731
|
+
await waitForEvent(service, () => service.start(), ["start"], ["error"], () => void 0);
|
|
1732
|
+
return getServiceStatus();
|
|
1733
|
+
}
|
|
1734
|
+
async function stopService() {
|
|
1735
|
+
ensureWindows();
|
|
1736
|
+
const status = await getServiceStatus();
|
|
1737
|
+
if (!status.installed || status.state === "stopped" || status.state === "stop-pending") {
|
|
1738
|
+
return status;
|
|
1739
|
+
}
|
|
1740
|
+
const service = createService();
|
|
1741
|
+
await waitForEvent(service, () => service.stop(), ["stop", "alreadystopped"], ["error"], () => void 0);
|
|
1742
|
+
return getServiceStatus();
|
|
1743
|
+
}
|
|
1744
|
+
async function runServiceProcess() {
|
|
1745
|
+
const logger = getLogger();
|
|
1746
|
+
const config = loadConfig();
|
|
1747
|
+
const task = startScheduler(config);
|
|
1748
|
+
logger.info({ cron: config.schedule.cron }, "Windows service process started");
|
|
1749
|
+
let shuttingDown = false;
|
|
1750
|
+
const shutdown = async (reason) => {
|
|
1751
|
+
if (shuttingDown) return;
|
|
1752
|
+
shuttingDown = true;
|
|
1753
|
+
logger.info({ reason }, "Windows service process stopping");
|
|
1754
|
+
try {
|
|
1755
|
+
await Promise.resolve(task.stop());
|
|
1756
|
+
} finally {
|
|
1757
|
+
closeDb();
|
|
1758
|
+
}
|
|
1759
|
+
logger.info("Windows service process stopped");
|
|
1760
|
+
process.exit(0);
|
|
1761
|
+
};
|
|
1762
|
+
process.on("SIGINT", () => {
|
|
1763
|
+
void shutdown("SIGINT");
|
|
1764
|
+
});
|
|
1765
|
+
process.on("SIGTERM", () => {
|
|
1766
|
+
void shutdown("SIGTERM");
|
|
1767
|
+
});
|
|
1768
|
+
process.on("message", (message) => {
|
|
1769
|
+
if (message === "shutdown") {
|
|
1770
|
+
void shutdown("shutdown");
|
|
1771
|
+
}
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// src/index.ts
|
|
1776
|
+
function formatServiceState(state) {
|
|
1777
|
+
return state.replace(/-/g, " ");
|
|
1778
|
+
}
|
|
1779
|
+
var program = new Command();
|
|
1780
|
+
program.name("synth").description("AI Coding work summary service").version("0.1.0");
|
|
1781
|
+
program.command("run").description("Collect sessions and generate a report immediately").option("--since <date>", "Specify the start time").option("--date <date>", "Generate the report for the given date").action(async (options) => {
|
|
1782
|
+
const config = loadConfig();
|
|
1783
|
+
await run(config, {
|
|
1784
|
+
since: options.since ? new Date(options.since) : void 0,
|
|
1785
|
+
date: options.date
|
|
1786
|
+
});
|
|
1787
|
+
});
|
|
1788
|
+
program.command("config").description("Manage configuration").option("--init", "Create the default config file").action((options) => {
|
|
1789
|
+
if (options.init) {
|
|
1790
|
+
console.log(initConfig());
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
console.log(JSON.stringify(loadConfig(), null, 2));
|
|
1794
|
+
});
|
|
1795
|
+
program.command("backfill-history").description("Clear collection state and rebuild historical reports from all sessions").action(async () => {
|
|
1796
|
+
const config = loadConfig();
|
|
1797
|
+
await backfillHistory(config);
|
|
1798
|
+
});
|
|
1799
|
+
program.command("install").description("Install and start the Windows service").action(async () => {
|
|
1800
|
+
const status = await installService();
|
|
1801
|
+
console.log(`service: ${status.installed ? "installed" : "not installed"}`);
|
|
1802
|
+
console.log(`state: ${formatServiceState(status.state)}`);
|
|
1803
|
+
});
|
|
1804
|
+
program.command("uninstall").description("Stop and remove the Windows service").action(async () => {
|
|
1805
|
+
const status = await uninstallService();
|
|
1806
|
+
console.log(`service: ${status.installed ? "installed" : "not installed"}`);
|
|
1807
|
+
console.log(`state: ${formatServiceState(status.state)}`);
|
|
1808
|
+
});
|
|
1809
|
+
program.command("start").description("Start the Windows service").action(async () => {
|
|
1810
|
+
const status = await startService();
|
|
1811
|
+
console.log(`service: ${status.installed ? "installed" : "not installed"}`);
|
|
1812
|
+
console.log(`state: ${formatServiceState(status.state)}`);
|
|
1813
|
+
});
|
|
1814
|
+
program.command("stop").description("Stop the Windows service").action(async () => {
|
|
1815
|
+
const status = await stopService();
|
|
1816
|
+
console.log(`service: ${status.installed ? "installed" : "not installed"}`);
|
|
1817
|
+
console.log(`state: ${formatServiceState(status.state)}`);
|
|
1818
|
+
});
|
|
1819
|
+
program.command("status").description("Show the Windows service status").action(async () => {
|
|
1820
|
+
const status = await getServiceStatus();
|
|
1821
|
+
console.log(`service: ${status.installed ? "installed" : "not installed"}`);
|
|
1822
|
+
console.log(`state: ${formatServiceState(status.state)}`);
|
|
1823
|
+
if (status.rawState) {
|
|
1824
|
+
console.log(`rawState: ${status.rawState}`);
|
|
1825
|
+
}
|
|
1826
|
+
});
|
|
1827
|
+
program.command("service-run").description("Internal service host command").action(async () => {
|
|
1828
|
+
await runServiceProcess();
|
|
1829
|
+
});
|
|
1830
|
+
await program.parseAsync();
|
|
1831
|
+
//# sourceMappingURL=index.js.map
|