pi-continuous-learning 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/cli/analyze-single-shot.d.ts +56 -0
  2. package/dist/cli/analyze-single-shot.d.ts.map +1 -0
  3. package/dist/cli/analyze-single-shot.js +83 -0
  4. package/dist/cli/analyze-single-shot.js.map +1 -0
  5. package/dist/cli/analyze.js +70 -81
  6. package/dist/cli/analyze.js.map +1 -1
  7. package/dist/instinct-tools.d.ts +10 -0
  8. package/dist/instinct-tools.d.ts.map +1 -1
  9. package/dist/instinct-tools.js +38 -1
  10. package/dist/instinct-tools.js.map +1 -1
  11. package/dist/observation-preprocessor.d.ts +26 -0
  12. package/dist/observation-preprocessor.d.ts.map +1 -0
  13. package/dist/observation-preprocessor.js +31 -0
  14. package/dist/observation-preprocessor.js.map +1 -0
  15. package/dist/prompts/analyzer-system-single-shot.d.ts +6 -0
  16. package/dist/prompts/analyzer-system-single-shot.d.ts.map +1 -0
  17. package/dist/prompts/analyzer-system-single-shot.js +124 -0
  18. package/dist/prompts/analyzer-system-single-shot.js.map +1 -0
  19. package/dist/prompts/analyzer-user-single-shot.d.ts +22 -0
  20. package/dist/prompts/analyzer-user-single-shot.d.ts.map +1 -0
  21. package/dist/prompts/analyzer-user-single-shot.js +53 -0
  22. package/dist/prompts/analyzer-user-single-shot.js.map +1 -0
  23. package/dist/prompts/analyzer-user.d.ts +3 -1
  24. package/dist/prompts/analyzer-user.d.ts.map +1 -1
  25. package/dist/prompts/analyzer-user.js +20 -7
  26. package/dist/prompts/analyzer-user.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/cli/analyze-single-shot.ts +145 -0
  29. package/src/cli/analyze.ts +82 -124
  30. package/src/instinct-tools.ts +42 -1
  31. package/src/observation-preprocessor.ts +48 -0
  32. package/src/prompts/analyzer-system-single-shot.ts +123 -0
  33. package/src/prompts/analyzer-user-single-shot.ts +88 -0
  34. package/src/prompts/analyzer-user.ts +26 -8
@@ -7,36 +7,35 @@ import {
7
7
  unlinkSync,
8
8
  } from "node:fs";
9
9
  import { join } from "node:path";
10
- import {
11
- createAgentSession,
12
- SessionManager,
13
- AuthStorage,
14
- ModelRegistry,
15
- DefaultResourceLoader,
16
- } from "@mariozechner/pi-coding-agent";
10
+ import { AuthStorage } from "@mariozechner/pi-coding-agent";
17
11
  import { getModel } from "@mariozechner/pi-ai";
18
12
 
19
13
  import { loadConfig, DEFAULT_CONFIG } from "../config.js";
20
- import type { ProjectEntry } from "../types.js";
14
+ import type { InstalledSkill, ProjectEntry } from "../types.js";
21
15
  import {
22
16
  getBaseDir,
23
17
  getProjectsRegistryPath,
24
18
  getObservationsPath,
25
19
  getProjectDir,
20
+ getProjectInstinctsDir,
21
+ getGlobalInstinctsDir,
26
22
  } from "../storage.js";
27
23
  import { countObservations } from "../observations.js";
28
24
  import { runDecayPass } from "../instinct-decay.js";
29
- import { buildAnalyzerUserPrompt, tailObservationsSince } from "../prompts/analyzer-user.js";
30
- import { buildAnalyzerSystemPrompt } from "./analyze-prompt.js";
25
+ import { tailObservationsSince } from "../prompts/analyzer-user.js";
26
+ import { buildSingleShotSystemPrompt } from "../prompts/analyzer-system-single-shot.js";
27
+ import { buildSingleShotUserPrompt } from "../prompts/analyzer-user-single-shot.js";
28
+ import {
29
+ runSingleShot,
30
+ buildInstinctFromChange,
31
+ } from "./analyze-single-shot.js";
31
32
  import {
32
- createInstinctListTool,
33
- createInstinctReadTool,
34
- createInstinctWriteTool,
35
- createInstinctDeleteTool,
36
- } from "../instinct-tools.js";
33
+ loadProjectInstincts,
34
+ loadGlobalInstincts,
35
+ saveInstinct,
36
+ } from "../instinct-store.js";
37
37
  import { readAgentsMd } from "../agents-md.js";
38
38
  import { homedir } from "node:os";
39
- import type { InstalledSkill } from "../types.js";
40
39
  import { AnalyzeLogger, type ProjectRunStats, type RunSummary } from "./analyze-logger.js";
41
40
 
42
41
  // ---------------------------------------------------------------------------
@@ -59,7 +58,6 @@ function acquireLock(baseDir: string): boolean {
59
58
  const lock = JSON.parse(content) as { pid: number; started_at: string };
60
59
  const age = Date.now() - new Date(lock.started_at).getTime();
61
60
 
62
- // Check if the owning process is still alive
63
61
  try {
64
62
  process.kill(lock.pid, 0); // signal 0 = existence check, no actual signal
65
63
  if (age < LOCK_STALE_MS) {
@@ -104,72 +102,6 @@ function startGlobalTimeout(timeoutMs: number, logger: AnalyzeLogger): void {
104
102
  }, timeoutMs).unref();
105
103
  }
106
104
 
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
-
173
105
  // ---------------------------------------------------------------------------
174
106
  // Per-project analysis
175
107
  // ---------------------------------------------------------------------------
@@ -237,10 +169,13 @@ async function analyzeProject(
237
169
 
238
170
  const obsPath = getObservationsPath(project.id, baseDir);
239
171
  const sinceLineCount = meta.last_observation_line_count ?? 0;
240
- const { lines: newObsLines, totalLineCount } = tailObservationsSince(obsPath, sinceLineCount);
172
+ const { lines: newObsLines, totalLineCount, rawLineCount } = tailObservationsSince(
173
+ obsPath,
174
+ sinceLineCount
175
+ );
241
176
 
242
177
  if (newObsLines.length === 0) {
243
- return { ran: false, skippedReason: "no new observation lines" };
178
+ return { ran: false, skippedReason: "no new observation lines after preprocessing" };
244
179
  }
245
180
 
246
181
  const obsCount = countObservations(project.id, baseDir);
@@ -249,11 +184,14 @@ async function analyzeProject(
249
184
  }
250
185
 
251
186
  const startTime = Date.now();
252
- logger.projectStart(project.id, project.name, newObsLines.length, obsCount);
187
+ logger.projectStart(project.id, project.name, rawLineCount, obsCount);
253
188
 
254
189
  runDecayPass(project.id, baseDir);
255
190
 
256
- const instinctsDir = join(getProjectDir(project.id, baseDir), "instincts", "personal");
191
+ // Load current instincts inline - no tool calls needed
192
+ const projectInstincts = loadProjectInstincts(project.id, baseDir);
193
+ const globalInstincts = loadGlobalInstincts(baseDir);
194
+ const allInstincts = [...projectInstincts, ...globalInstincts];
257
195
 
258
196
  const agentsMdProject = readAgentsMd(join(project.root, "AGENTS.md"));
259
197
  const agentsMdGlobal = readAgentsMd(join(homedir(), ".pi", "agent", "AGENTS.md"));
@@ -270,68 +208,89 @@ async function analyzeProject(
270
208
  // Skills loading is best-effort - continue without them
271
209
  }
272
210
 
273
- const userPrompt = buildAnalyzerUserPrompt(obsPath, instinctsDir, project, {
211
+ const userPrompt = buildSingleShotUserPrompt(project, allInstincts, newObsLines, {
274
212
  agentsMdProject,
275
213
  agentsMdGlobal,
276
214
  installedSkills,
277
- observationLines: newObsLines,
278
215
  });
279
216
 
280
217
  const authStorage = AuthStorage.create();
281
- const modelRegistry = new ModelRegistry(authStorage);
282
218
  const modelId = (config.model || DEFAULT_CONFIG.model) as Parameters<typeof getModel>[1];
283
219
  const model = getModel("anthropic", modelId);
220
+ const apiKey = await authStorage.getApiKey("anthropic");
221
+
222
+ if (!apiKey) {
223
+ throw new Error("No Anthropic API key configured. Set via auth.json or ANTHROPIC_API_KEY.");
224
+ }
284
225
 
285
- // Track instinct operations
286
- const instinctCounts: InstinctOpCounts = { created: 0, updated: 0, deleted: 0 };
287
- const trackedTools = wrapInstinctToolsWithTracking(project.id, project.name, baseDir, instinctCounts);
226
+ const context = {
227
+ systemPrompt: buildSingleShotSystemPrompt(),
228
+ messages: [
229
+ { role: "user" as const, content: userPrompt, timestamp: Date.now() },
230
+ ],
231
+ };
288
232
 
289
- const customTools = [
290
- trackedTools.listTool,
291
- trackedTools.readTool,
292
- trackedTools.writeTool,
293
- trackedTools.deleteTool,
294
- ];
233
+ const timeoutMs = (config.timeout_seconds ?? DEFAULT_CONFIG.timeout_seconds) * 1000;
234
+ const abortController = new AbortController();
235
+ const timeoutHandle = setTimeout(() => abortController.abort(), timeoutMs);
295
236
 
296
- const loader = new DefaultResourceLoader({
297
- systemPromptOverride: () => buildAnalyzerSystemPrompt(),
298
- });
299
- await loader.reload();
300
-
301
- const { session } = await createAgentSession({
302
- model,
303
- authStorage,
304
- modelRegistry,
305
- sessionManager: SessionManager.inMemory(),
306
- customTools,
307
- resourceLoader: loader,
308
- });
237
+ const instinctCounts = { created: 0, updated: 0, deleted: 0 };
238
+ const projectInstinctsDir = getProjectInstinctsDir(project.id, "personal", baseDir);
239
+ const globalInstinctsDir = getGlobalInstinctsDir("personal", baseDir);
309
240
 
241
+ let singleShotMessage;
310
242
  try {
311
- await session.prompt(userPrompt);
243
+ const result = await runSingleShot(context, model, apiKey, abortController.signal);
244
+ singleShotMessage = result.message;
245
+
246
+ for (const change of result.changes) {
247
+ if (change.action === "delete") {
248
+ const id = change.id;
249
+ if (!id) continue;
250
+ const dir = change.scope === "global" ? globalInstinctsDir : projectInstinctsDir;
251
+ const filePath = join(dir, `${id}.md`);
252
+ if (existsSync(filePath)) {
253
+ unlinkSync(filePath);
254
+ instinctCounts.deleted++;
255
+ }
256
+ } else {
257
+ // create or update
258
+ const existing = allInstincts.find((i) => i.id === change.instinct?.id) ?? null;
259
+ const instinct = buildInstinctFromChange(change, existing, project.id);
260
+ if (!instinct) continue;
261
+
262
+ const dir = instinct.scope === "global" ? globalInstinctsDir : projectInstinctsDir;
263
+ saveInstinct(instinct, dir);
264
+
265
+ if (change.action === "create") {
266
+ instinctCounts.created++;
267
+ } else {
268
+ instinctCounts.updated++;
269
+ }
270
+ }
271
+ }
312
272
  } finally {
313
- session.dispose();
273
+ clearTimeout(timeoutHandle);
314
274
  }
315
275
 
316
- // Collect stats after session completes
317
- const sessionStats = session.getSessionStats();
276
+ const usage = singleShotMessage!.usage;
318
277
  const durationMs = Date.now() - startTime;
319
278
 
320
279
  const stats: ProjectRunStats = {
321
280
  project_id: project.id,
322
281
  project_name: project.name,
323
282
  duration_ms: durationMs,
324
- observations_processed: newObsLines.length,
283
+ observations_processed: rawLineCount,
325
284
  observations_total: obsCount,
326
285
  instincts_created: instinctCounts.created,
327
286
  instincts_updated: instinctCounts.updated,
328
287
  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,
288
+ tokens_input: usage.input,
289
+ tokens_output: usage.output,
290
+ tokens_cache_read: usage.cacheRead,
291
+ tokens_cache_write: usage.cacheWrite,
292
+ tokens_total: usage.totalTokens,
293
+ cost_usd: usage.cost.total,
335
294
  model: modelId,
336
295
  };
337
296
 
@@ -420,7 +379,6 @@ async function main(): Promise<void> {
420
379
 
421
380
  main().catch((err) => {
422
381
  releaseLock(getBaseDir());
423
- // Last-resort logging - config may not have loaded
424
382
  const logger = new AnalyzeLogger();
425
383
  logger.error("Fatal error", err);
426
384
  process.exit(1);
@@ -72,6 +72,9 @@ const WriteParams = Type.Object({
72
72
 
73
73
  const DeleteParams = Type.Object({
74
74
  id: Type.String({ description: "Instinct ID to delete" }),
75
+ scope: Type.Optional(StringEnum(["project", "global"] as const, {
76
+ description: "Target scope. If omitted, falls back to priority order (project first, then global).",
77
+ })),
75
78
  });
76
79
 
77
80
  const MergeParams = Type.Object({
@@ -85,7 +88,14 @@ const MergeParams = Type.Object({
85
88
  scope: StringEnum(["project", "global"] as const),
86
89
  evidence: Type.Optional(Type.Array(Type.String())),
87
90
  }),
88
- delete_ids: Type.Array(Type.String(), { description: "IDs of source instincts to remove after merge" }),
91
+ delete_ids: Type.Array(Type.String(), { description: "IDs of source instincts to remove after merge (uses priority lookup)" }),
92
+ delete_scoped_ids: Type.Optional(Type.Array(
93
+ Type.Object({
94
+ id: Type.String({ description: "Instinct ID" }),
95
+ scope: StringEnum(["project", "global"] as const, { description: "Scope of the copy to delete" }),
96
+ }),
97
+ { description: "Scope-aware deletions: [{id, scope}] to target a specific copy" }
98
+ )),
89
99
  });
90
100
 
91
101
  export type InstinctListInput = Static<typeof ListParams>;
@@ -247,6 +257,22 @@ export function createInstinctDeleteTool(
247
257
  _onUpdate: unknown,
248
258
  _ctx: unknown
249
259
  ) {
260
+ if (params.scope) {
261
+ const dir = getInstinctsDir(params.scope, projectId, baseDir);
262
+ if (!dir) {
263
+ throw new Error(`Cannot target project scope: no project detected`);
264
+ }
265
+ const path = join(dir, `${params.id}.md`);
266
+ if (!existsSync(path)) {
267
+ throw new Error(`Instinct not found: ${params.id} in ${params.scope} scope`);
268
+ }
269
+ unlinkSync(path);
270
+ return {
271
+ content: [{ type: "text" as const, text: `Deleted instinct: ${params.id} (${params.scope}-scoped)` }],
272
+ details: { id: params.id, scope: params.scope },
273
+ };
274
+ }
275
+
250
276
  const found = findInstinctFile(params.id, projectId, baseDir);
251
277
  if (!found) {
252
278
  throw new Error(`Instinct not found: ${params.id}`);
@@ -311,6 +337,21 @@ export function createInstinctMergeTool(
311
337
  }
312
338
  }
313
339
 
340
+ for (const { id, scope } of params.delete_scoped_ids ?? []) {
341
+ // Skip only when both ID and scope match the merged result (already written above)
342
+ if (id === merged.id && scope === merged.scope) continue;
343
+ const dir = getInstinctsDir(scope, projectId, baseDir);
344
+ if (!dir) {
345
+ throw new Error(`Cannot target project scope: no project detected`);
346
+ }
347
+ const path = join(dir, `${id}.md`);
348
+ if (!existsSync(path)) {
349
+ throw new Error(`Instinct not found: ${id} in ${scope} scope`);
350
+ }
351
+ unlinkSync(path);
352
+ deleted.push(`${id}(${scope})`);
353
+ }
354
+
314
355
  return {
315
356
  content: [{
316
357
  type: "text" as const,
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Observation preprocessing for cost reduction.
3
+ *
4
+ * Strips high-volume, low-signal data from raw observation events before
5
+ * sending to the LLM analyzer. Reduces context size by ~80% on typical sessions.
6
+ *
7
+ * Rules:
8
+ * - turn_start → DROP (no information not already in turn_end)
9
+ * - tool_start → DROP (tool name + sequence captured by tool_complete)
10
+ * - tool_complete, is_error: false → KEEP, strip output field
11
+ * - tool_complete, is_error: true → KEEP as-is (error message needed)
12
+ * - all others → KEEP as-is
13
+ */
14
+ import type { Observation } from "./types.js";
15
+
16
+ /**
17
+ * Preprocess a single observation.
18
+ * Returns null if the observation should be dropped entirely.
19
+ * Returns a new (immutable) observation with large fields stripped if applicable.
20
+ */
21
+ export function preprocessObservation(obs: Observation): Observation | null {
22
+ if (obs.event === "turn_start" || obs.event === "tool_start") {
23
+ return null;
24
+ }
25
+
26
+ if (obs.event === "tool_complete" && !obs.is_error) {
27
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
28
+ const { output: _, ...stripped } = obs;
29
+ return stripped as Observation;
30
+ }
31
+
32
+ return obs;
33
+ }
34
+
35
+ /**
36
+ * Preprocess an array of raw observations.
37
+ * Drops nulls and returns only the meaningful events.
38
+ */
39
+ export function preprocessObservations(observations: Observation[]): Observation[] {
40
+ const result: Observation[] = [];
41
+ for (const obs of observations) {
42
+ const processed = preprocessObservation(obs);
43
+ if (processed !== null) {
44
+ result.push(processed);
45
+ }
46
+ }
47
+ return result;
48
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * System prompt for the single-shot (non-agentic) background analyzer.
3
+ * Instructs the model to return a JSON change-set instead of using tool calls.
4
+ */
5
+ export function buildSingleShotSystemPrompt(): string {
6
+ return `You are a coding behavior analyst. Your job is to read session observations
7
+ and produce a JSON change-set to create or update instinct files that capture reusable coding patterns.
8
+
9
+ ## Output Format
10
+
11
+ Return ONLY a valid JSON object (no prose, no markdown fences) with this structure:
12
+
13
+ {
14
+ "changes": [
15
+ {
16
+ "action": "create",
17
+ "instinct": {
18
+ "id": "kebab-case-id",
19
+ "title": "Short title",
20
+ "trigger": "When this should activate",
21
+ "action": "What the agent should do (verb phrase)",
22
+ "confidence": 0.5,
23
+ "domain": "typescript",
24
+ "scope": "project",
25
+ "observation_count": 3,
26
+ "confirmed_count": 0,
27
+ "contradicted_count": 0,
28
+ "inactive_count": 0,
29
+ "evidence": ["brief note 1", "brief note 2"]
30
+ }
31
+ },
32
+ {
33
+ "action": "update",
34
+ "instinct": { "...same fields as create..." }
35
+ },
36
+ {
37
+ "action": "delete",
38
+ "id": "instinct-id-to-delete",
39
+ "scope": "project"
40
+ }
41
+ ]
42
+ }
43
+
44
+ Return { "changes": [] } if no changes are needed.
45
+
46
+ ## Pattern Detection Heuristics
47
+
48
+ Analyze observations for these categories:
49
+
50
+ ### User Corrections
51
+ - User rephrases a request after an agent response
52
+ - User explicitly rejects an approach
53
+ - Trigger: the corrected behavior; Action: the preferred approach
54
+
55
+ ### Error Resolutions
56
+ - Tool call returns is_error: true followed by a successful retry
57
+ - Trigger: the error condition; Action: the proven resolution
58
+
59
+ ### Repeated Workflows
60
+ - Same sequence of tool calls appears 3+ times
61
+ - Trigger: the workflow start condition; Action: the efficient path
62
+
63
+ ### Tool Preferences
64
+ - Agent consistently uses one tool over alternatives
65
+ - Trigger: the task type; Action: the preferred tool and parameters
66
+
67
+ ### Anti-Patterns
68
+ - Actions that consistently lead to errors or user corrections
69
+ - Trigger: the bad pattern situation; Action: what to do instead
70
+
71
+ ### Turn Structure
72
+ - turn_end events summarize turns: tool_count and error_count
73
+ - High error_count turns suggest inefficient approaches
74
+
75
+ ### Context Pressure
76
+ - session_compact events signal context window pressure
77
+
78
+ ### User Shell Commands
79
+ - user_bash events capture manual shell commands the user runs
80
+ - Repeated commands after agent actions reveal verification patterns
81
+
82
+ ### Model Preferences
83
+ - model_select events track when users switch models
84
+
85
+ ## Feedback Analysis
86
+
87
+ Each observation may include an active_instincts field listing instinct IDs
88
+ that were injected into the agent's system prompt before that turn.
89
+
90
+ Use this to update existing instinct confidence scores:
91
+ - Confirmed (+0.05): instinct was active and agent followed guidance without correction
92
+ - Contradicted (-0.15): instinct was active but user corrected the agent
93
+ - Inactive (no change): instinct was injected but trigger never arose
94
+
95
+ When updating, increment the corresponding count field and recalculate confidence.
96
+
97
+ ## Confidence Scoring Rules
98
+
99
+ ### Initial Confidence (new instincts)
100
+ - 1-2 observations -> 0.3
101
+ - 3-5 observations -> 0.5
102
+ - 6-10 observations -> 0.7
103
+ - 11+ observations -> 0.85
104
+
105
+ ### Clamping
106
+ - Always clamp to [0.1, 0.9]
107
+
108
+ ## Scope Decision Guide
109
+
110
+ Use project scope when the pattern is specific to this project's tech stack or conventions.
111
+ Use global scope when the pattern applies universally to any coding session.
112
+ When in doubt, prefer project scope.
113
+
114
+ ## Conservativeness Rules
115
+
116
+ 1. Only create a new instinct with 3+ clear independent observations supporting the pattern.
117
+ 2. No code snippets in the action field - plain language only.
118
+ 3. Each instinct must have one well-defined trigger.
119
+ 4. New instincts from observation data alone are capped at 0.85 confidence.
120
+ 5. Check existing instincts (provided in the user message) for duplicates before creating. Update instead.
121
+ 6. Write actions as clear instructions starting with a verb.
122
+ 7. Be skeptical of outliers - patterns seen only in unusual circumstances should not become instincts.`;
123
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * User prompt builder for the single-shot background analyzer.
3
+ * Includes current instincts inline (no tool calls needed) and filtered observations.
4
+ */
5
+ import type { InstalledSkill, Instinct, ProjectEntry } from "../types.js";
6
+ import { formatInstinctsForPrompt } from "../cli/analyze-single-shot.js";
7
+
8
+ export interface SingleShotPromptOptions {
9
+ agentsMdProject?: string | null;
10
+ agentsMdGlobal?: string | null;
11
+ installedSkills?: InstalledSkill[];
12
+ }
13
+
14
+ /**
15
+ * Builds the user prompt for the single-shot analyzer.
16
+ * Embeds all current instincts inline so the model has full context
17
+ * without making any tool calls.
18
+ *
19
+ * @param project - Project metadata
20
+ * @param existingInstincts - All current instincts (project + global)
21
+ * @param observationLines - Preprocessed observation lines (JSONL strings)
22
+ * @param options - Optional AGENTS.md content and installed skills
23
+ */
24
+ export function buildSingleShotUserPrompt(
25
+ project: ProjectEntry,
26
+ existingInstincts: Instinct[],
27
+ observationLines: string[],
28
+ options: SingleShotPromptOptions = {}
29
+ ): string {
30
+ const { agentsMdProject = null, agentsMdGlobal = null, installedSkills = [] } = options;
31
+
32
+ const observationBlock =
33
+ observationLines.length > 0
34
+ ? observationLines.join("\n")
35
+ : "(no observations recorded yet)";
36
+
37
+ const instinctBlock = formatInstinctsForPrompt(existingInstincts);
38
+
39
+ const parts: string[] = [
40
+ "## Project Context",
41
+ "",
42
+ `project_id: ${project.id}`,
43
+ `project_name: ${project.name}`,
44
+ "",
45
+ "## Existing Instincts",
46
+ "",
47
+ instinctBlock,
48
+ "",
49
+ "## New Observations (preprocessed)",
50
+ "",
51
+ "```",
52
+ observationBlock,
53
+ "```",
54
+ ];
55
+
56
+ if (agentsMdProject != null || agentsMdGlobal != null) {
57
+ parts.push("", "## Existing Guidelines", "");
58
+ if (agentsMdProject != null) {
59
+ parts.push("### Project AGENTS.md", "", agentsMdProject, "");
60
+ }
61
+ if (agentsMdGlobal != null) {
62
+ parts.push("### Global AGENTS.md", "", agentsMdGlobal, "");
63
+ }
64
+ }
65
+
66
+ if (installedSkills.length > 0) {
67
+ parts.push("", "## Installed Skills", "");
68
+ for (const skill of installedSkills) {
69
+ parts.push(`- **${skill.name}**: ${skill.description}`);
70
+ }
71
+ parts.push("");
72
+ }
73
+
74
+ parts.push(
75
+ "",
76
+ "## Instructions",
77
+ "",
78
+ "1. Review the existing instincts above.",
79
+ "2. Analyze the new observations for patterns per the system prompt rules.",
80
+ "3. Return a JSON change-set: create new instincts, update existing ones, or delete obsolete ones.",
81
+ "4. Apply feedback analysis using the active_instincts field in each observation.",
82
+ "5. Passive confidence decay has already been applied before this analysis.",
83
+ "",
84
+ "Return ONLY the JSON object. No prose, no markdown fences."
85
+ );
86
+
87
+ return parts.join("\n");
88
+ }
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  import { existsSync, readFileSync } from "node:fs";
8
- import type { InstalledSkill, ProjectEntry } from "../types.js";
8
+ import type { InstalledSkill, Observation, ProjectEntry } from "../types.js";
9
+ import { preprocessObservations } from "../observation-preprocessor.js";
9
10
 
10
11
  /** Maximum number of observation lines to include in analysis. */
11
12
  const MAX_TAIL_ENTRIES = 500;
@@ -35,15 +36,18 @@ export function tailObservations(
35
36
  export interface TailSinceResult {
36
37
  lines: string[];
37
38
  totalLineCount: number;
39
+ /** Number of raw new lines before preprocessing. */
40
+ rawLineCount: number;
38
41
  }
39
42
 
40
43
  export function tailObservationsSince(
41
44
  observationsPath: string,
42
45
  sinceLineCount: number,
43
- maxEntries = MAX_TAIL_ENTRIES
46
+ maxEntries = MAX_TAIL_ENTRIES,
47
+ preprocess = true
44
48
  ): TailSinceResult {
45
49
  if (!existsSync(observationsPath)) {
46
- return { lines: [], totalLineCount: 0 };
50
+ return { lines: [], totalLineCount: 0, rawLineCount: 0 };
47
51
  }
48
52
  const content = readFileSync(observationsPath, "utf-8");
49
53
  const allLines = content
@@ -55,12 +59,26 @@ export function tailObservationsSince(
55
59
 
56
60
  // If file was archived/reset (fewer lines than cursor), treat as fresh
57
61
  const effectiveSince = totalLineCount < sinceLineCount ? 0 : sinceLineCount;
58
- const newLines = allLines.slice(effectiveSince);
62
+ const newLines = allLines.slice(effectiveSince).slice(-maxEntries);
63
+ const rawLineCount = newLines.length;
64
+
65
+ if (!preprocess) {
66
+ return { lines: newLines, totalLineCount, rawLineCount };
67
+ }
68
+
69
+ const parsed: Observation[] = [];
70
+ for (const line of newLines) {
71
+ try {
72
+ parsed.push(JSON.parse(line) as Observation);
73
+ } catch {
74
+ // skip malformed lines
75
+ }
76
+ }
77
+
78
+ const filtered = preprocessObservations(parsed);
79
+ const lines = filtered.map((obs) => JSON.stringify(obs));
59
80
 
60
- return {
61
- lines: newLines.slice(-maxEntries),
62
- totalLineCount,
63
- };
81
+ return { lines, totalLineCount, rawLineCount };
64
82
  }
65
83
 
66
84
  export interface AnalyzerUserPromptOptions {