Skip to content

Commit ad91fce

Browse files
authored
feat: add facts/knowledge notes memory class alongside instincts (#68)
Closes #65 ## Summary - Introduces **facts** — a second memory class for declarative knowledge that doesn't fit the trigger→action instinct model (e.g. "The test DB port is 3306", "Use `pnpm build:fast` to compile") - Facts are **user-driven**: created in-session via four new LLM tools (`fact_write`, `fact_read`, `fact_list`, `fact_delete`) — the user says "remember that X" and Pi stores it directly - The background analyzer does **not** detect facts from observations; it only runs decay and cleanup passes on them (keeping the design simple and reliable) - Facts are injected into the system prompt as a `## Project Knowledge` block after the instincts block, using the remaining char budget ## New files | File | Purpose | |---|---| | `src/fact-parser.ts` | Parse/serialize fact `.md` files (YAML frontmatter + body); mirrors `instinct-parser.ts` without `trigger`/`action`/`graduated_to` | | `src/fact-store.ts` | CRUD with 5s mtime-based cache; mirrors `instinct-store.ts` | | `src/fact-decay.ts` | Passive confidence decay (−0.05/week); reuses `applyPassiveDecay` from `confidence.ts` | | `src/fact-cleanup.ts` | Flagged removal, zero-confirmation TTL, hard cap; no contradiction detection | | `src/fact-tools.ts` | Registers `fact_write`, `fact_read`, `fact_list`, `fact_delete` LLM tools | | `src/*.test.ts` | Tests for all new modules (834 tests total, all passing) | ## Modified files - **`types.ts`** — `Fact` interface + `FactScope`/`FactSource`; three new `Config` fields - **`storage.ts`** — `getProjectFactsDir`/`getGlobalFactsDir` helpers; `ensureStorageLayout` creates `facts/personal` dirs - **`instinct-injector.ts`** — `buildFactsInjectionBlock` + `## Project Knowledge` block appended after instincts - **`instinct-status.ts`** — `/instinct-status` now shows a `Facts / Knowledge Notes` section - **`cli/analyze.ts`** — `runFactCleanupPass` + `runFactDecayPass` wired into both analysis and consolidation passes - **`index.ts`** — `registerAllFactTools` called in `session_start` - **`README.md`** — Facts feature section, injection example, tool table, config table, storage layout ## Test plan - [ ] `npm run check -w packages/pi-continuous-learning` passes (834 tests, lint, typecheck) - [ ] Tell Pi "remember that the test DB port is 3306" — verify Pi calls `fact_write` and the file appears in `~/.pi/continuous-learning/projects/<hash>/facts/personal/` - [ ] Run `/instinct-status` — verify a `Facts / Knowledge Notes` section appears - [ ] Run `pi-cl-analyze` — verify it completes without error (decay/cleanup pass runs on empty facts dirs) - [ ] Ask Pi "what facts do I have?" — verify it calls `fact_list`
1 parent 2924d9e commit ad91fce

20 files changed

Lines changed: 1682 additions & 9 deletions

packages/pi-continuous-learning/README.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ Before each agent turn, the injector loads instincts relevant to the current pro
8080
## Learned Behaviours (Instincts)
8181
- [0.75] when modifying code files: Always search with grep to find relevant context before editing
8282
- [0.68] when debugging errors: Read stack traces to understand root cause before suggesting fixes
83+
84+
## Project Knowledge
85+
- [0.80] The test database runs on port 3306
86+
- [0.72] Use pnpm build:fast for incremental TypeScript compilation
8387
```
8488

8589
### 4. Feedback loop
@@ -159,6 +163,22 @@ Before creating or updating instincts, a deterministic (no LLM cost) contradicti
159163

160164
Every write is checked against existing instincts using Jaccard similarity. If any existing instinct scores >= 0.6 similarity, the write is blocked and the LLM is instructed to update the existing one instead. This keeps the corpus clean without human effort.
161165

166+
### Facts / Knowledge Notes
167+
168+
Alongside behavioural instincts, the extension maintains a second memory class: **facts**. A fact is a declarative statement with no trigger or action — just something that is true.
169+
170+
```
171+
Instinct: when modifying API routes → always update the OpenAPI spec
172+
Fact: The staging environment lives at staging.example.com
173+
```
174+
175+
Facts are **user-driven** — you create them by telling Pi to remember something during a session. Pi then calls the `fact_write` tool directly:
176+
177+
> "Remember that the test database port is 3306"
178+
> "Store the fact that we use pnpm build:fast for incremental TypeScript compilation"
179+
180+
Facts are injected into the system prompt as a separate `## Project Knowledge` block after the instincts block. They participate in the same confidence and decay system as instincts — facts that aren't confirmed decay over time and are eventually pruned. The background analyzer handles this maintenance automatically; it does **not** try to detect or create facts from observations.
181+
162182
---
163183

164184
## Slash commands
@@ -185,8 +205,12 @@ The LLM can call these directly during conversation — no slash command needed:
185205
| `instinct_write` | Create or update an instinct |
186206
| `instinct_delete` | Remove an instinct by ID |
187207
| `instinct_merge` | Merge multiple instincts into one |
208+
| `fact_list` | List knowledge facts with optional scope/domain filters |
209+
| `fact_read` | Read a specific fact by ID |
210+
| `fact_write` | Create or update a knowledge fact |
211+
| `fact_delete` | Remove a fact by ID |
188212

189-
Ask Pi things like _"show me my instincts"_, _"merge these two"_, or _"delete anything with low confidence"_ and it will use these tools directly.
213+
Ask Pi things like _"show me my instincts"_, _"merge these two"_, or _"remember that the DB port is 3306"_ and it will use these tools directly.
190214

191215
---
192216

@@ -363,7 +387,10 @@ All defaults work out of the box. Override at `~/.pi/continuous-learning/config.
363387
"consolidation_min_sessions": 10,
364388
"max_total_instincts_per_project": 100,
365389
"max_total_instincts_global": 200,
366-
"max_new_instincts_per_run": 10
390+
"max_new_instincts_per_run": 10,
391+
"max_facts_per_project": 30,
392+
"max_facts_global": 50,
393+
"max_new_facts_per_run": 3
367394
}
368395
```
369396

@@ -385,6 +412,9 @@ All defaults work out of the box. Override at `~/.pi/continuous-learning/config.
385412
| `max_total_instincts_per_project` | 100 | Hard cap; oldest low-confidence instincts are pruned first |
386413
| `max_total_instincts_global` | 200 | Hard cap for global instincts |
387414
| `max_new_instincts_per_run` | 10 | Rate limit on instinct creation per analyzer run |
415+
| `max_facts_per_project` | 30 | Hard cap on facts per project; lowest-confidence pruned first |
416+
| `max_facts_global` | 50 | Hard cap on global facts |
417+
| `max_new_facts_per_run` | 3 | Rate limit on fact creation per analyzer run |
388418
| `log_path` | `~/.pi/continuous-learning/analyzer.log` | Analyzer log file path |
389419

390420
---
@@ -400,11 +430,13 @@ All data stays local on your machine:
400430
analyze.lock # Present only while analyzer runs
401431
analyzer.log # Structured JSON event log
402432
instincts/personal/ # Global instincts
433+
facts/personal/ # Global facts
403434
projects/<hash>/
404435
project.json # Project metadata + analysis cursor
405436
observations.jsonl # Current observations
406437
observations.archive/ # Archived (auto-purged after 30 days)
407438
instincts/personal/ # Project-scoped instincts
439+
facts/personal/ # Project-scoped facts
408440
```
409441

410442
---

packages/pi-continuous-learning/src/cli/analyze.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import { buildConsolidateUserPrompt } from "../prompts/consolidate-user.js";
3232
import { countObservations } from "../observations.js";
3333
import { runDecayPass } from "../instinct-decay.js";
3434
import { runCleanupPass } from "../instinct-cleanup.js";
35+
import { runFactDecayPass } from "../fact-decay.js";
36+
import { runFactCleanupPass } from "../fact-cleanup.js";
3537
import { tailObservationsSince } from "../prompts/analyzer-user.js";
3638
import { buildSingleShotSystemPrompt } from "../prompts/analyzer-system-single-shot.js";
3739
import { buildSingleShotUserPrompt } from "../prompts/analyzer-user-single-shot.js";
@@ -290,6 +292,8 @@ async function analyzeProject(
290292

291293
runCleanupPass(project.id, config, baseDir);
292294
runDecayPass(project.id, baseDir);
295+
runFactCleanupPass(project.id, config, baseDir);
296+
runFactDecayPass(project.id, baseDir);
293297

294298
// Load current instincts inline - no tool calls needed
295299
const projectInstincts = loadProjectInstincts(project.id, baseDir);
@@ -627,6 +631,8 @@ async function consolidateProject(
627631
// Run cleanup and decay before consolidation
628632
runCleanupPass(project.id, config, baseDir);
629633
runDecayPass(project.id, baseDir);
634+
runFactCleanupPass(project.id, config, baseDir);
635+
runFactDecayPass(project.id, baseDir);
630636

631637
// Load all instincts
632638
const projectInstincts = loadProjectInstincts(project.id, baseDir);

packages/pi-continuous-learning/src/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ export const DEFAULT_CONFIG: Config = {
9292
consolidation_min_sessions: DEFAULT_CONSOLIDATION_MIN_SESSIONS,
9393
recurring_prompt_min_sessions: 3,
9494
recurring_prompt_score_boost: 3,
95+
// Facts volume control
96+
max_facts_per_project: 30,
97+
max_facts_global: 50,
98+
max_new_facts_per_run: 3,
9599
};
96100

97101
// ---------------------------------------------------------------------------
@@ -122,6 +126,9 @@ const PartialConfigSchema = Type.Partial(
122126
consolidation_min_sessions: Type.Number(),
123127
recurring_prompt_min_sessions: Type.Number(),
124128
recurring_prompt_score_boost: Type.Number(),
129+
max_facts_per_project: Type.Number(),
130+
max_facts_global: Type.Number(),
131+
max_new_facts_per_run: Type.Number(),
125132
}),
126133
);
127134

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { describe, it, expect, afterEach } from "vitest";
2+
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
3+
import { join } from "node:path";
4+
import { tmpdir } from "node:os";
5+
import {
6+
cleanupFlaggedFacts,
7+
cleanupZeroConfirmedFacts,
8+
enforceFactCap,
9+
runFactCleanupPass,
10+
} from "./fact-cleanup.js";
11+
import { saveFact, listFacts, invalidateFactCache } from "./fact-store.js";
12+
import type { Fact, Config } from "./types.js";
13+
import { DEFAULT_CONFIG } from "./config.js";
14+
15+
function makeTmpDir(): string {
16+
return mkdtempSync(join(tmpdir(), "pi-cl-fact-cleanup-test-"));
17+
}
18+
19+
function makeFact(id: string, overrides: Partial<Fact> = {}): Fact {
20+
const old = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(); // 60 days ago
21+
return {
22+
id,
23+
title: id,
24+
content: `Fact: ${id}`,
25+
confidence: 0.5,
26+
domain: "workflow",
27+
source: "personal",
28+
scope: "project",
29+
created_at: old,
30+
updated_at: old,
31+
observation_count: 1,
32+
confirmed_count: 0,
33+
contradicted_count: 0,
34+
inactive_count: 0,
35+
...overrides,
36+
};
37+
}
38+
39+
const BASE_CONFIG: Config = {
40+
...DEFAULT_CONFIG,
41+
};
42+
43+
describe("cleanupFlaggedFacts", () => {
44+
let tmpDir: string;
45+
afterEach(() => {
46+
rmSync(tmpDir, { recursive: true, force: true });
47+
invalidateFactCache();
48+
});
49+
50+
it("deletes facts flagged_for_removal older than threshold", () => {
51+
tmpDir = makeTmpDir();
52+
saveFact(makeFact("flagged-old", { flagged_for_removal: true }), tmpDir);
53+
const deleted = cleanupFlaggedFacts(tmpDir, 7);
54+
expect(deleted).toBe(1);
55+
expect(listFacts(tmpDir)).toHaveLength(0);
56+
});
57+
58+
it("does not delete facts flagged recently (below threshold)", () => {
59+
tmpDir = makeTmpDir();
60+
const recentlyFlagged = makeFact("flagged-new", {
61+
flagged_for_removal: true,
62+
updated_at: new Date().toISOString(),
63+
});
64+
saveFact(recentlyFlagged, tmpDir);
65+
const deleted = cleanupFlaggedFacts(tmpDir, 7);
66+
expect(deleted).toBe(0);
67+
});
68+
69+
it("does not delete unflagged facts", () => {
70+
tmpDir = makeTmpDir();
71+
saveFact(makeFact("normal"), tmpDir);
72+
const deleted = cleanupFlaggedFacts(tmpDir, 7);
73+
expect(deleted).toBe(0);
74+
expect(listFacts(tmpDir)).toHaveLength(1);
75+
});
76+
});
77+
78+
describe("cleanupZeroConfirmedFacts", () => {
79+
let tmpDir: string;
80+
afterEach(() => {
81+
rmSync(tmpDir, { recursive: true, force: true });
82+
invalidateFactCache();
83+
});
84+
85+
it("deletes zero-confirmed facts older than TTL", () => {
86+
tmpDir = makeTmpDir();
87+
saveFact(makeFact("old-unconfirmed", { confirmed_count: 0 }), tmpDir);
88+
const deleted = cleanupZeroConfirmedFacts(tmpDir, 7);
89+
expect(deleted).toBe(1);
90+
});
91+
92+
it("does not delete confirmed facts even if old", () => {
93+
tmpDir = makeTmpDir();
94+
saveFact(makeFact("confirmed", { confirmed_count: 3 }), tmpDir);
95+
const deleted = cleanupZeroConfirmedFacts(tmpDir, 7);
96+
expect(deleted).toBe(0);
97+
});
98+
99+
it("does not delete recently created zero-confirmed facts", () => {
100+
tmpDir = makeTmpDir();
101+
const recent = makeFact("new-fact", {
102+
confirmed_count: 0,
103+
created_at: new Date().toISOString(),
104+
});
105+
saveFact(recent, tmpDir);
106+
const deleted = cleanupZeroConfirmedFacts(tmpDir, 28);
107+
expect(deleted).toBe(0);
108+
});
109+
});
110+
111+
describe("enforceFactCap", () => {
112+
let tmpDir: string;
113+
afterEach(() => {
114+
rmSync(tmpDir, { recursive: true, force: true });
115+
invalidateFactCache();
116+
});
117+
118+
it("deletes lowest-confidence facts when over cap", () => {
119+
tmpDir = makeTmpDir();
120+
saveFact(makeFact("low", { confidence: 0.2, confirmed_count: 1 }), tmpDir);
121+
saveFact(makeFact("mid", { confidence: 0.5, confirmed_count: 1 }), tmpDir);
122+
saveFact(makeFact("high", { confidence: 0.8, confirmed_count: 1 }), tmpDir);
123+
const deleted = enforceFactCap(tmpDir, 2);
124+
expect(deleted).toBe(1);
125+
const remaining = listFacts(tmpDir);
126+
expect(remaining).toHaveLength(2);
127+
expect(remaining.find((f) => f.id === "low")).toBeUndefined();
128+
});
129+
130+
it("does nothing when at or below cap", () => {
131+
tmpDir = makeTmpDir();
132+
saveFact(makeFact("a", { confirmed_count: 1 }), tmpDir);
133+
saveFact(makeFact("b", { confirmed_count: 1 }), tmpDir);
134+
expect(enforceFactCap(tmpDir, 2)).toBe(0);
135+
expect(enforceFactCap(tmpDir, 5)).toBe(0);
136+
});
137+
});
138+
139+
describe("runFactCleanupPass", () => {
140+
let tmpDir: string;
141+
afterEach(() => {
142+
rmSync(tmpDir, { recursive: true, force: true });
143+
invalidateFactCache();
144+
});
145+
146+
it("runs all cleanup rules and returns aggregated results", () => {
147+
tmpDir = makeTmpDir();
148+
const projectDir = join(tmpDir, "projects", "p1", "facts", "personal");
149+
mkdirSync(projectDir, { recursive: true });
150+
saveFact(
151+
makeFact("flagged", { flagged_for_removal: true }),
152+
projectDir,
153+
);
154+
const result = runFactCleanupPass("p1", BASE_CONFIG, tmpDir);
155+
expect(result.flaggedDeleted).toBe(1);
156+
expect(result.total).toBeGreaterThanOrEqual(1);
157+
});
158+
159+
it("returns zero result when no facts exist", () => {
160+
tmpDir = makeTmpDir();
161+
const result = runFactCleanupPass("no-project", BASE_CONFIG, tmpDir);
162+
expect(result.total).toBe(0);
163+
});
164+
});

0 commit comments

Comments
 (0)