pi-continuous-learning 0.3.0 → 0.5.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.
@@ -37,13 +37,14 @@ import {
37
37
  import { readAgentsMd } from "../agents-md.js";
38
38
  import { homedir } from "node:os";
39
39
  import type { InstalledSkill } from "../types.js";
40
+ import { AnalyzeLogger, type ProjectRunStats, type RunSummary } from "./analyze-logger.js";
40
41
 
41
42
  // ---------------------------------------------------------------------------
42
- // Lockfile guard ensures only one instance runs at a time
43
+ // Lockfile guard - ensures only one instance runs at a time
43
44
  // ---------------------------------------------------------------------------
44
45
 
45
46
  const LOCKFILE_NAME = "analyze.lock";
46
- const LOCK_STALE_MS = 10 * 60 * 1000; // 10 minutes stale lock threshold
47
+ const LOCK_STALE_MS = 10 * 60 * 1000; // 10 minutes - stale lock threshold
47
48
 
48
49
  function getLockfilePath(baseDir: string): string {
49
50
  return join(baseDir, LOCKFILE_NAME);
@@ -64,12 +65,12 @@ function acquireLock(baseDir: string): boolean {
64
65
  if (age < LOCK_STALE_MS) {
65
66
  return false; // Process alive and lock is fresh
66
67
  }
67
- // Process alive but lock is stale treat as abandoned
68
+ // Process alive but lock is stale - treat as abandoned
68
69
  } catch {
69
- // Process is dead lock is orphaned, safe to take over
70
+ // Process is dead - lock is orphaned, safe to take over
70
71
  }
71
72
  } catch {
72
- // Malformed lockfile remove and proceed
73
+ // Malformed lockfile - remove and proceed
73
74
  }
74
75
  }
75
76
 
@@ -86,7 +87,7 @@ function releaseLock(baseDir: string): void {
86
87
  try {
87
88
  if (existsSync(lockPath)) unlinkSync(lockPath);
88
89
  } catch {
89
- // Best effort don't crash on cleanup
90
+ // Best effort - don't crash on cleanup
90
91
  }
91
92
  }
92
93
 
@@ -96,13 +97,79 @@ function releaseLock(baseDir: string): void {
96
97
 
97
98
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes total
98
99
 
99
- function startGlobalTimeout(timeoutMs: number): void {
100
+ function startGlobalTimeout(timeoutMs: number, logger: AnalyzeLogger): void {
100
101
  setTimeout(() => {
101
- console.error("[analyze] Global timeout reached. Exiting.");
102
+ logger.error("Global timeout reached, forcing exit");
102
103
  process.exit(2);
103
104
  }, timeoutMs).unref();
104
105
  }
105
106
 
107
+ // ---------------------------------------------------------------------------
108
+ // Instinct operation tracking
109
+ // ---------------------------------------------------------------------------
110
+
111
+ interface InstinctOpCounts {
112
+ created: number;
113
+ updated: number;
114
+ deleted: number;
115
+ }
116
+
117
+ /**
118
+ * Wraps instinct tools to count create/update/delete operations.
119
+ * Returns new tool instances that increment the provided counts.
120
+ */
121
+ function wrapInstinctToolsWithTracking(
122
+ projectId: string,
123
+ projectName: string,
124
+ baseDir: string,
125
+ counts: InstinctOpCounts
126
+ ) {
127
+ const writeTool = createInstinctWriteTool(projectId, projectName, baseDir);
128
+ const deleteTool = createInstinctDeleteTool(projectId, baseDir);
129
+
130
+ const trackedWrite = {
131
+ ...writeTool,
132
+ async execute(
133
+ toolCallId: string,
134
+ params: Parameters<typeof writeTool.execute>[1],
135
+ signal: AbortSignal | undefined,
136
+ onUpdate: unknown,
137
+ ctx: unknown
138
+ ) {
139
+ const result = await writeTool.execute(toolCallId, params, signal, onUpdate, ctx);
140
+ const details = result.details as { action?: string } | undefined;
141
+ if (details?.action === "created") {
142
+ counts.created++;
143
+ } else {
144
+ counts.updated++;
145
+ }
146
+ return result;
147
+ },
148
+ };
149
+
150
+ const trackedDelete = {
151
+ ...deleteTool,
152
+ async execute(
153
+ toolCallId: string,
154
+ params: Parameters<typeof deleteTool.execute>[1],
155
+ signal: AbortSignal | undefined,
156
+ onUpdate: unknown,
157
+ ctx: unknown
158
+ ) {
159
+ const result = await deleteTool.execute(toolCallId, params, signal, onUpdate, ctx);
160
+ counts.deleted++;
161
+ return result;
162
+ },
163
+ };
164
+
165
+ return {
166
+ listTool: createInstinctListTool(projectId, baseDir),
167
+ readTool: createInstinctReadTool(projectId, baseDir),
168
+ writeTool: trackedWrite,
169
+ deleteTool: trackedDelete,
170
+ };
171
+ }
172
+
106
173
  // ---------------------------------------------------------------------------
107
174
  // Per-project analysis
108
175
  // ---------------------------------------------------------------------------
@@ -150,27 +217,39 @@ function hasNewObservations(projectId: string, meta: ProjectMeta, baseDir: strin
150
217
  return true;
151
218
  }
152
219
 
220
+ interface AnalyzeResult {
221
+ readonly ran: boolean;
222
+ readonly stats?: ProjectRunStats;
223
+ readonly skippedReason?: string;
224
+ }
225
+
153
226
  async function analyzeProject(
154
227
  project: ProjectEntry,
155
228
  config: ReturnType<typeof loadConfig>,
156
- baseDir: string
157
- ): Promise<boolean> {
229
+ baseDir: string,
230
+ logger: AnalyzeLogger
231
+ ): Promise<AnalyzeResult> {
158
232
  const meta = loadProjectMeta(project.id, baseDir);
159
233
 
160
- if (!hasNewObservations(project.id, meta, baseDir)) return false;
234
+ if (!hasNewObservations(project.id, meta, baseDir)) {
235
+ return { ran: false, skippedReason: "no new observations" };
236
+ }
161
237
 
162
238
  const obsPath = getObservationsPath(project.id, baseDir);
163
239
  const sinceLineCount = meta.last_observation_line_count ?? 0;
164
240
  const { lines: newObsLines, totalLineCount } = tailObservationsSince(obsPath, sinceLineCount);
165
241
 
166
- if (newObsLines.length === 0) return false;
242
+ if (newObsLines.length === 0) {
243
+ return { ran: false, skippedReason: "no new observation lines" };
244
+ }
167
245
 
168
246
  const obsCount = countObservations(project.id, baseDir);
169
- if (obsCount < config.min_observations_to_analyze) return false;
247
+ if (obsCount < config.min_observations_to_analyze) {
248
+ return { ran: false, skippedReason: `below threshold (${obsCount}/${config.min_observations_to_analyze})` };
249
+ }
170
250
 
171
- console.log(
172
- `[analyze] Processing ${project.name} (${project.id}): ${newObsLines.length} new observations (${obsCount} total)`
173
- );
251
+ const startTime = Date.now();
252
+ logger.projectStart(project.id, project.name, newObsLines.length, obsCount);
174
253
 
175
254
  runDecayPass(project.id, baseDir);
176
255
 
@@ -188,7 +267,7 @@ async function analyzeProject(
188
267
  description: s.description,
189
268
  }));
190
269
  } catch {
191
- // Skills loading is best-effort continue without them
270
+ // Skills loading is best-effort - continue without them
192
271
  }
193
272
 
194
273
  const userPrompt = buildAnalyzerUserPrompt(obsPath, instinctsDir, project, {
@@ -203,11 +282,15 @@ async function analyzeProject(
203
282
  const modelId = (config.model || DEFAULT_CONFIG.model) as Parameters<typeof getModel>[1];
204
283
  const model = getModel("anthropic", modelId);
205
284
 
285
+ // Track instinct operations
286
+ const instinctCounts: InstinctOpCounts = { created: 0, updated: 0, deleted: 0 };
287
+ const trackedTools = wrapInstinctToolsWithTracking(project.id, project.name, baseDir, instinctCounts);
288
+
206
289
  const customTools = [
207
- createInstinctListTool(project.id, baseDir),
208
- createInstinctReadTool(project.id, baseDir),
209
- createInstinctWriteTool(project.id, project.name, baseDir),
210
- createInstinctDeleteTool(project.id, baseDir),
290
+ trackedTools.listTool,
291
+ trackedTools.readTool,
292
+ trackedTools.writeTool,
293
+ trackedTools.deleteTool,
211
294
  ];
212
295
 
213
296
  const loader = new DefaultResourceLoader({
@@ -230,13 +313,37 @@ async function analyzeProject(
230
313
  session.dispose();
231
314
  }
232
315
 
316
+ // Collect stats after session completes
317
+ const sessionStats = session.getSessionStats();
318
+ const durationMs = Date.now() - startTime;
319
+
320
+ const stats: ProjectRunStats = {
321
+ project_id: project.id,
322
+ project_name: project.name,
323
+ duration_ms: durationMs,
324
+ observations_processed: newObsLines.length,
325
+ observations_total: obsCount,
326
+ instincts_created: instinctCounts.created,
327
+ instincts_updated: instinctCounts.updated,
328
+ instincts_deleted: instinctCounts.deleted,
329
+ tokens_input: sessionStats.tokens.input,
330
+ tokens_output: sessionStats.tokens.output,
331
+ tokens_cache_read: sessionStats.tokens.cacheRead,
332
+ tokens_cache_write: sessionStats.tokens.cacheWrite,
333
+ tokens_total: sessionStats.tokens.total,
334
+ cost_usd: sessionStats.cost,
335
+ model: modelId,
336
+ };
337
+
338
+ logger.projectComplete(stats);
339
+
233
340
  saveProjectMeta(
234
341
  project.id,
235
342
  { ...meta, last_analyzed_at: new Date().toISOString(), last_observation_line_count: totalLineCount },
236
343
  baseDir
237
344
  );
238
- console.log(`[analyze] Completed ${project.name}`);
239
- return true;
345
+
346
+ return { ran: true, stats };
240
347
  }
241
348
 
242
349
  // ---------------------------------------------------------------------------
@@ -245,35 +352,67 @@ async function analyzeProject(
245
352
 
246
353
  async function main(): Promise<void> {
247
354
  const baseDir = getBaseDir();
355
+ const config = loadConfig();
356
+ const logger = new AnalyzeLogger(config.log_path);
248
357
 
249
358
  if (!acquireLock(baseDir)) {
250
- console.log("[analyze] Another instance is already running. Exiting.");
359
+ logger.info("Another instance is already running, exiting");
251
360
  process.exit(0);
252
361
  }
253
362
 
254
- startGlobalTimeout(DEFAULT_TIMEOUT_MS);
363
+ startGlobalTimeout(DEFAULT_TIMEOUT_MS, logger);
364
+
365
+ const runStart = Date.now();
255
366
 
256
367
  try {
257
- const config = loadConfig();
258
368
  const registry = loadProjectsRegistry(baseDir);
259
369
  const projects = Object.values(registry);
260
370
 
261
371
  if (projects.length === 0) {
262
- console.log("[analyze] No projects registered. Use pi with the continuous-learning extension first.");
372
+ logger.info("No projects registered");
263
373
  return;
264
374
  }
265
375
 
376
+ logger.runStart(projects.length);
377
+
266
378
  let processed = 0;
379
+ let skipped = 0;
380
+ let errored = 0;
381
+ const allProjectStats: ProjectRunStats[] = [];
382
+
267
383
  for (const project of projects) {
268
384
  try {
269
- const didRun = await analyzeProject(project, config, baseDir);
270
- if (didRun) processed++;
385
+ const result = await analyzeProject(project, config, baseDir, logger);
386
+ if (result.ran && result.stats) {
387
+ processed++;
388
+ allProjectStats.push(result.stats);
389
+ } else {
390
+ skipped++;
391
+ if (result.skippedReason) {
392
+ logger.projectSkipped(project.id, project.name, result.skippedReason);
393
+ }
394
+ }
271
395
  } catch (err) {
272
- console.error(`[analyze] Error processing ${project.name}: ${String(err)}`);
396
+ errored++;
397
+ logger.projectError(project.id, project.name, err);
273
398
  }
274
399
  }
275
400
 
276
- console.log(`[analyze] Done. Processed ${processed}/${projects.length} project(s).`);
401
+ const summary: RunSummary = {
402
+ total_duration_ms: Date.now() - runStart,
403
+ projects_processed: processed,
404
+ projects_skipped: skipped,
405
+ projects_errored: errored,
406
+ projects_total: projects.length,
407
+ total_tokens: allProjectStats.reduce((sum, s) => sum + s.tokens_total, 0),
408
+ total_cost_usd: allProjectStats.reduce((sum, s) => sum + s.cost_usd, 0),
409
+ total_instincts_created: allProjectStats.reduce((sum, s) => sum + s.instincts_created, 0),
410
+ total_instincts_updated: allProjectStats.reduce((sum, s) => sum + s.instincts_updated, 0),
411
+ total_instincts_deleted: allProjectStats.reduce((sum, s) => sum + s.instincts_deleted, 0),
412
+ project_stats: allProjectStats,
413
+ };
414
+
415
+ logger.runComplete(summary);
277
416
  } finally {
278
417
  releaseLock(baseDir);
279
418
  }
@@ -281,6 +420,8 @@ async function main(): Promise<void> {
281
420
 
282
421
  main().catch((err) => {
283
422
  releaseLock(getBaseDir());
284
- console.error(`[analyze] Fatal error: ${String(err)}`);
423
+ // Last-resort logging - config may not have loaded
424
+ const logger = new AnalyzeLogger();
425
+ logger.error("Fatal error", err);
285
426
  process.exit(1);
286
427
  });
package/src/config.ts CHANGED
@@ -67,6 +67,7 @@ const PartialConfigSchema = Type.Partial(
67
67
  active_hours_start: Type.Number(),
68
68
  active_hours_end: Type.Number(),
69
69
  max_idle_seconds: Type.Number(),
70
+ log_path: Type.String(),
70
71
  })
71
72
  );
72
73
 
package/src/index.ts CHANGED
@@ -15,6 +15,13 @@ import { ensureStorageLayout } from "./storage.js";
15
15
  import { cleanOldArchives } from "./observations.js";
16
16
  import { handleToolStart, handleToolEnd } from "./tool-observer.js";
17
17
  import { handleBeforeAgentStart, handleAgentEnd } from "./prompt-observer.js";
18
+ import {
19
+ handleTurnStart,
20
+ handleTurnEnd,
21
+ handleUserBash,
22
+ handleSessionCompact,
23
+ handleModelSelect,
24
+ } from "./session-observer.js";
18
25
  import {
19
26
  handleBeforeAgentStartInjection,
20
27
  handleAgentEndClearInstincts,
@@ -98,6 +105,51 @@ export default function (pi: ExtensionAPI): void {
98
105
  }
99
106
  });
100
107
 
108
+ pi.on("turn_start", (event, ctx) => {
109
+ try {
110
+ if (!project) return;
111
+ handleTurnStart(event, ctx, project);
112
+ } catch (err) {
113
+ logError(project?.id ?? null, "turn_start", err);
114
+ }
115
+ });
116
+
117
+ pi.on("turn_end", (event, ctx) => {
118
+ try {
119
+ if (!project) return;
120
+ handleTurnEnd(event, ctx, project);
121
+ } catch (err) {
122
+ logError(project?.id ?? null, "turn_end", err);
123
+ }
124
+ });
125
+
126
+ pi.on("user_bash", (event, ctx) => {
127
+ try {
128
+ if (!project) return;
129
+ handleUserBash(event, ctx, project);
130
+ } catch (err) {
131
+ logError(project?.id ?? null, "user_bash", err);
132
+ }
133
+ });
134
+
135
+ pi.on("session_compact", (event, ctx) => {
136
+ try {
137
+ if (!project) return;
138
+ handleSessionCompact(event, ctx, project);
139
+ } catch (err) {
140
+ logError(project?.id ?? null, "session_compact", err);
141
+ }
142
+ });
143
+
144
+ pi.on("model_select", (event, ctx) => {
145
+ try {
146
+ if (!project) return;
147
+ handleModelSelect(event, ctx, project);
148
+ } catch (err) {
149
+ logError(project?.id ?? null, "model_select", err);
150
+ }
151
+ });
152
+
101
153
  pi.registerCommand(STATUS_CMD, {
102
154
  description: "Show all instincts grouped by domain with confidence scores",
103
155
  handler: (args: string, ctx: ExtensionCommandContext) =>
@@ -76,12 +76,16 @@ export function handleAgentEnd(
76
76
  try {
77
77
  if (shouldSkipObservation()) return;
78
78
 
79
+ const contextUsage = ctx.getContextUsage();
80
+ const tokensUsed = contextUsage?.tokens ?? undefined;
81
+
79
82
  const observation: Observation = {
80
83
  timestamp: new Date().toISOString(),
81
84
  event: "agent_end",
82
85
  session: getSessionId(ctx),
83
86
  project_id: project.id,
84
87
  project_name: project.name,
88
+ ...(tokensUsed != null ? { tokens_used: tokensUsed } : {}),
85
89
  ...buildActiveInstincts(),
86
90
  };
87
91
 
@@ -0,0 +1,185 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+
3
+ import { getCurrentActiveInstincts } from "./active-instincts.js";
4
+ import { appendObservation } from "./observations.js";
5
+ import { scrubSecrets } from "./scrubber.js";
6
+ import { logError } from "./error-logger.js";
7
+ import type { Observation, ProjectEntry } from "./types.js";
8
+
9
+ export interface TurnStartEvent {
10
+ type: "turn_start";
11
+ turnIndex: number;
12
+ timestamp: number;
13
+ }
14
+
15
+ export interface TurnEndEvent {
16
+ type: "turn_end";
17
+ turnIndex: number;
18
+ message: unknown;
19
+ toolResults: unknown[];
20
+ }
21
+
22
+ export interface UserBashEvent {
23
+ type: "user_bash";
24
+ command: string;
25
+ excludeFromContext: boolean;
26
+ cwd: string;
27
+ }
28
+
29
+ export interface SessionCompactEvent {
30
+ type: "session_compact";
31
+ compactionEntry: unknown;
32
+ fromExtension: boolean;
33
+ }
34
+
35
+ export interface ModelSelectEvent {
36
+ type: "model_select";
37
+ model: { id?: string; name?: string };
38
+ previousModel: { id?: string; name?: string } | undefined;
39
+ source: "set" | "cycle" | "restore";
40
+ }
41
+
42
+ function getSessionId(ctx: ExtensionContext): string {
43
+ return ctx.sessionManager.getSessionId();
44
+ }
45
+
46
+ function buildActiveInstincts(): Pick<Observation, "active_instincts"> {
47
+ const ids = getCurrentActiveInstincts();
48
+ return ids.length > 0 ? { active_instincts: ids } : {};
49
+ }
50
+
51
+ export function handleTurnStart(
52
+ event: TurnStartEvent,
53
+ ctx: ExtensionContext,
54
+ project: ProjectEntry,
55
+ baseDir?: string
56
+ ): void {
57
+ try {
58
+ const observation: Observation = {
59
+ timestamp: new Date().toISOString(),
60
+ event: "turn_start",
61
+ session: getSessionId(ctx),
62
+ project_id: project.id,
63
+ project_name: project.name,
64
+ turn_index: event.turnIndex,
65
+ ...buildActiveInstincts(),
66
+ };
67
+
68
+ appendObservation(observation, project.id, baseDir);
69
+ } catch (err) {
70
+ logError(project.id, "session-observer:handleTurnStart", err, baseDir);
71
+ }
72
+ }
73
+
74
+ export function handleTurnEnd(
75
+ event: TurnEndEvent,
76
+ ctx: ExtensionContext,
77
+ project: ProjectEntry,
78
+ baseDir?: string
79
+ ): void {
80
+ try {
81
+ const toolCount = event.toolResults?.length ?? 0;
82
+ const errorCount = Array.isArray(event.toolResults)
83
+ ? event.toolResults.filter((r: unknown) => {
84
+ if (r && typeof r === "object" && "isError" in r) {
85
+ return (r as { isError: boolean }).isError;
86
+ }
87
+ return false;
88
+ }).length
89
+ : 0;
90
+
91
+ const contextUsage = ctx.getContextUsage();
92
+ const tokensUsed = contextUsage?.tokens ?? undefined;
93
+
94
+ const observation: Observation = {
95
+ timestamp: new Date().toISOString(),
96
+ event: "turn_end",
97
+ session: getSessionId(ctx),
98
+ project_id: project.id,
99
+ project_name: project.name,
100
+ turn_index: event.turnIndex,
101
+ tool_count: toolCount,
102
+ error_count: errorCount,
103
+ ...(tokensUsed != null ? { tokens_used: tokensUsed } : {}),
104
+ ...buildActiveInstincts(),
105
+ };
106
+
107
+ appendObservation(observation, project.id, baseDir);
108
+ } catch (err) {
109
+ logError(project.id, "session-observer:handleTurnEnd", err, baseDir);
110
+ }
111
+ }
112
+
113
+ export function handleUserBash(
114
+ event: UserBashEvent,
115
+ ctx: ExtensionContext,
116
+ project: ProjectEntry,
117
+ baseDir?: string
118
+ ): void {
119
+ try {
120
+ const observation: Observation = {
121
+ timestamp: new Date().toISOString(),
122
+ event: "user_bash",
123
+ session: getSessionId(ctx),
124
+ project_id: project.id,
125
+ project_name: project.name,
126
+ command: scrubSecrets(event.command),
127
+ cwd: event.cwd,
128
+ ...buildActiveInstincts(),
129
+ };
130
+
131
+ appendObservation(observation, project.id, baseDir);
132
+ } catch (err) {
133
+ logError(project.id, "session-observer:handleUserBash", err, baseDir);
134
+ }
135
+ }
136
+
137
+ export function handleSessionCompact(
138
+ event: SessionCompactEvent,
139
+ ctx: ExtensionContext,
140
+ project: ProjectEntry,
141
+ baseDir?: string
142
+ ): void {
143
+ try {
144
+ const observation: Observation = {
145
+ timestamp: new Date().toISOString(),
146
+ event: "session_compact",
147
+ session: getSessionId(ctx),
148
+ project_id: project.id,
149
+ project_name: project.name,
150
+ from_extension: event.fromExtension,
151
+ ...buildActiveInstincts(),
152
+ };
153
+
154
+ appendObservation(observation, project.id, baseDir);
155
+ } catch (err) {
156
+ logError(project.id, "session-observer:handleSessionCompact", err, baseDir);
157
+ }
158
+ }
159
+
160
+ export function handleModelSelect(
161
+ event: ModelSelectEvent,
162
+ ctx: ExtensionContext,
163
+ project: ProjectEntry,
164
+ baseDir?: string
165
+ ): void {
166
+ try {
167
+ const modelName = event.model?.id ?? event.model?.name ?? "unknown";
168
+ const previousModelName = event.previousModel?.id ?? event.previousModel?.name;
169
+
170
+ const observation: Observation = {
171
+ timestamp: new Date().toISOString(),
172
+ event: "model_select",
173
+ session: getSessionId(ctx),
174
+ project_id: project.id,
175
+ project_name: project.name,
176
+ model: modelName,
177
+ ...(previousModelName ? { previous_model: previousModelName } : {}),
178
+ model_change_source: event.source,
179
+ };
180
+
181
+ appendObservation(observation, project.id, baseDir);
182
+ } catch (err) {
183
+ logError(project.id, "session-observer:handleModelSelect", err, baseDir);
184
+ }
185
+ }
package/src/types.ts CHANGED
@@ -11,7 +11,12 @@ export type ObservationEvent =
11
11
  | "tool_start"
12
12
  | "tool_complete"
13
13
  | "user_prompt"
14
- | "agent_end";
14
+ | "agent_end"
15
+ | "turn_start"
16
+ | "turn_end"
17
+ | "user_bash"
18
+ | "session_compact"
19
+ | "model_select";
15
20
 
16
21
  export interface Observation {
17
22
  timestamp: string; // ISO 8601 UTC
@@ -24,6 +29,16 @@ export interface Observation {
24
29
  output?: string;
25
30
  is_error?: boolean;
26
31
  active_instincts?: string[];
32
+ turn_index?: number;
33
+ tool_count?: number;
34
+ error_count?: number;
35
+ tokens_used?: number;
36
+ command?: string;
37
+ cwd?: string;
38
+ from_extension?: boolean;
39
+ model?: string;
40
+ previous_model?: string;
41
+ model_change_source?: string;
27
42
  }
28
43
 
29
44
  // ---------------------------------------------------------------------------
@@ -87,4 +102,5 @@ export interface Config {
87
102
  active_hours_start: number; // 0-23
88
103
  active_hours_end: number; // 0-23
89
104
  max_idle_seconds: number;
105
+ log_path?: string; // Override analyzer log location (default: ~/.pi/continuous-learning/analyzer.log)
90
106
  }