pi-orchestrator-config 4.1.5
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 +808 -0
- package/agents/api-documenter.md +32 -0
- package/agents/bash-expert.md +53 -0
- package/agents/code-reviewer-docs.md +145 -0
- package/agents/code-reviewer-guidelines.md +97 -0
- package/agents/code-reviewer-quality.md +142 -0
- package/agents/code-reviewer-security.md +107 -0
- package/agents/code-reviewer-spec.md +164 -0
- package/agents/debugger.md +63 -0
- package/agents/docker-expert.md +40 -0
- package/agents/docs-fetcher.md +48 -0
- package/agents/git-expert.md +63 -0
- package/agents/github-expert.md +83 -0
- package/agents/go-expert.md +62 -0
- package/agents/issue-reviewer-feasibility.md +60 -0
- package/agents/issue-reviewer-scope.md +63 -0
- package/agents/issue-reviewer-spec.md +77 -0
- package/agents/java-expert.md +31 -0
- package/agents/jenkins-expert.md +38 -0
- package/agents/kubernetes-expert.md +40 -0
- package/agents/planner.md +42 -0
- package/agents/python-expert.md +61 -0
- package/agents/reviewer.md +26 -0
- package/agents/scout.md +40 -0
- package/agents/security-auditor.md +254 -0
- package/agents/technical-documentation-writer.md +37 -0
- package/agents/test-automator.md +55 -0
- package/agents/test-runner.md +47 -0
- package/agents/ts-expert.md +23 -0
- package/agents/worker.md +30 -0
- package/extensions/acpx-provider/index.ts +104 -0
- package/extensions/acpx-provider/load-runtime.ts +99 -0
- package/extensions/acpx-provider/runtime-models.ts +40 -0
- package/extensions/cli-provider/agents/claude.ts +128 -0
- package/extensions/cli-provider/agents/cursor.ts +84 -0
- package/extensions/cli-provider/agents/gemini.ts +100 -0
- package/extensions/cli-provider/discover.ts +77 -0
- package/extensions/cli-provider/index.ts +57 -0
- package/extensions/cli-provider/parsers.ts +304 -0
- package/extensions/cli-provider/providers.ts +58 -0
- package/extensions/cli-provider/runner.ts +225 -0
- package/extensions/cli-provider/runtime-models.ts +32 -0
- package/extensions/cli-provider/session-reaper.ts +122 -0
- package/extensions/cli-provider/sessions.ts +533 -0
- package/extensions/cli-provider/shared/discover-cache.ts +235 -0
- package/extensions/cli-provider/types.ts +48 -0
- package/extensions/coms/coms-net-server.ts +1642 -0
- package/extensions/coms/coms-net-wrapper.ts +521 -0
- package/extensions/coms/coms-net.ts +1589 -0
- package/extensions/coms/coms-p2p.ts +1879 -0
- package/extensions/coms/coms-shared.ts +510 -0
- package/extensions/coms/coms-wrapper.ts +138 -0
- package/extensions/coms/index.ts +20 -0
- package/extensions/coms/themeMap.ts +147 -0
- package/extensions/image-gen/image-gen.ts +332 -0
- package/extensions/image-gen/index.ts +14 -0
- package/extensions/orchestrator/agents.ts +134 -0
- package/extensions/orchestrator/ask-user.ts +293 -0
- package/extensions/orchestrator/async-agents.ts +1037 -0
- package/extensions/orchestrator/async-capability.ts +129 -0
- package/extensions/orchestrator/async-runner.ts +324 -0
- package/extensions/orchestrator/async-status-parse.ts +31 -0
- package/extensions/orchestrator/async-status-ui.ts +201 -0
- package/extensions/orchestrator/async-wait.ts +36 -0
- package/extensions/orchestrator/btw.ts +210 -0
- package/extensions/orchestrator/cron-status-format.ts +126 -0
- package/extensions/orchestrator/cron-status-ui.ts +132 -0
- package/extensions/orchestrator/cron.ts +730 -0
- package/extensions/orchestrator/dreaming.ts +355 -0
- package/extensions/orchestrator/enforcement-helpers.ts +530 -0
- package/extensions/orchestrator/enforcement-rules.ts +329 -0
- package/extensions/orchestrator/enforcement.ts +828 -0
- package/extensions/orchestrator/extended-autocomplete.ts +593 -0
- package/extensions/orchestrator/git-helpers.ts +420 -0
- package/extensions/orchestrator/github-autocomplete.ts +250 -0
- package/extensions/orchestrator/icons.ts +17 -0
- package/extensions/orchestrator/index.ts +100 -0
- package/extensions/orchestrator/memory-embeddings.ts +281 -0
- package/extensions/orchestrator/memory-promotion.ts +449 -0
- package/extensions/orchestrator/memory-provenance.ts +80 -0
- package/extensions/orchestrator/memory-query-class.ts +90 -0
- package/extensions/orchestrator/memory-scoring.ts +434 -0
- package/extensions/orchestrator/memory-tools.ts +883 -0
- package/extensions/orchestrator/memory-tree.ts +212 -0
- package/extensions/orchestrator/nvim.ts +169 -0
- package/extensions/orchestrator/overlay-dashboard-utils.ts +25 -0
- package/extensions/orchestrator/overlay-dashboard.ts +512 -0
- package/extensions/orchestrator/pi-config-review-state.ts +303 -0
- package/extensions/orchestrator/preference-extractor.ts +106 -0
- package/extensions/orchestrator/project-settings.ts +468 -0
- package/extensions/orchestrator/promotion-queue.ts +311 -0
- package/extensions/orchestrator/resolve-agent-model.ts +32 -0
- package/extensions/orchestrator/review-ui.ts +204 -0
- package/extensions/orchestrator/rule-placeholders.ts +12 -0
- package/extensions/orchestrator/rules.ts +481 -0
- package/extensions/orchestrator/session-search.ts +243 -0
- package/extensions/orchestrator/session-validation.ts +374 -0
- package/extensions/orchestrator/situation-report.ts +370 -0
- package/extensions/orchestrator/status-bar.ts +31 -0
- package/extensions/orchestrator/status-line.ts +192 -0
- package/extensions/orchestrator/status.ts +110 -0
- package/extensions/orchestrator/subagent-tool.ts +1748 -0
- package/extensions/orchestrator/task-lifecycle.ts +88 -0
- package/extensions/orchestrator/utils.ts +227 -0
- package/extensions/pidash/index.ts +15 -0
- package/extensions/pidash/pidash.ts +941 -0
- package/extensions/pidiff/index.ts +13 -0
- package/extensions/pidiff/pidiff.ts +369 -0
- package/extensions/providers/acpx-driver.ts +479 -0
- package/extensions/providers/built-in-drivers.ts +52 -0
- package/extensions/providers/claude-driver.ts +319 -0
- package/extensions/providers/cursor-acpx-driver.ts +398 -0
- package/extensions/providers/cursor-cli-driver.ts +484 -0
- package/extensions/providers/gemini-driver.ts +302 -0
- package/extensions/providers/index.ts +513 -0
- package/extensions/shared/build-system-prompt.ts +57 -0
- package/extensions/shared/create-runtime-provider.ts +190 -0
- package/extensions/shared/daemon-manager.ts +248 -0
- package/extensions/shared/file-logger.ts +115 -0
- package/extensions/shared/managed-refresh.ts +211 -0
- package/extensions/shared/provider-driver.ts +295 -0
- package/extensions/shared/provider-errors.ts +305 -0
- package/extensions/shared/provider-registry.ts +388 -0
- package/extensions/shared/stream-builder.ts +275 -0
- package/extensions/shared/types.ts +20 -0
- package/extensions/shared/ui/README.md +10 -0
- package/extensions/shared/ui/badge.tsx +52 -0
- package/extensions/shared/ui/button.tsx +58 -0
- package/extensions/shared/ui/collapsible.tsx +21 -0
- package/extensions/shared/ui/dropdown-menu.tsx +266 -0
- package/extensions/shared/ui/input.tsx +20 -0
- package/extensions/shared/ui/popover.tsx +114 -0
- package/extensions/shared/ui/resizable.tsx +48 -0
- package/extensions/shared/ui/scroll-area.tsx +53 -0
- package/extensions/shared/ui/select.tsx +201 -0
- package/extensions/shared/ui/separator.tsx +25 -0
- package/extensions/shared/ui/tooltip.tsx +64 -0
- package/extensions/shared/ws-client.ts +74 -0
- package/package.json +59 -0
- package/prompts/coderabbit-rate-limit.md +95 -0
- package/prompts/create-coms-feature-manager.md +186 -0
- package/prompts/create-skill.md +128 -0
- package/prompts/domain-model.md +109 -0
- package/prompts/external-ai.md +665 -0
- package/prompts/implement-and-review.md +25 -0
- package/prompts/implement.md +26 -0
- package/prompts/issue-review.md +256 -0
- package/prompts/pr-review.md +753 -0
- package/prompts/query-db.md +154 -0
- package/prompts/refine-review.md +208 -0
- package/prompts/release.md +235 -0
- package/prompts/remember.md +33 -0
- package/prompts/review-handler-status.md +27 -0
- package/prompts/review-handler.md +677 -0
- package/prompts/review-local.md +159 -0
- package/prompts/scout-and-plan.md +21 -0
- package/rules/00-orchestrator-core.md +42 -0
- package/rules/05-issue-first-workflow.md +119 -0
- package/rules/10-agent-routing.md +58 -0
- package/rules/15-mcp-launchpad.md +46 -0
- package/rules/20-code-review-loop.md +234 -0
- package/rules/25-documentation-updates.md +15 -0
- package/rules/30-prompt-templates.md +19 -0
- package/rules/35-memory.md +240 -0
- package/rules/40-critical-rules.md +124 -0
- package/rules/45-file-preview.md +29 -0
- package/rules/50-agent-bug-reporting.md +90 -0
- package/rules/55-coms-protocol.md +63 -0
- package/rules/60-task-tracking.md +66 -0
- package/skills/conflict-resolver/SKILL.md +213 -0
- package/skills/skill-creator/SKILL.md +85 -0
- package/skills/systematic-debugging/SKILL.md +353 -0
- package/skills/test-driven-development/SKILL.md +335 -0
- package/skills/update-agents-md/SKILL.md +282 -0
- package/templates/coms-feature-manager-prompt.md +313 -0
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Tools — AI-accessible tools for searching, recalling, and reinforcing memories.
|
|
3
|
+
*
|
|
4
|
+
* Registers pi tools that the LLM can call to interact with the memory system:
|
|
5
|
+
* - memory_search: search memories by keyword
|
|
6
|
+
* - memory_reinforce: reinforce an existing memory (bump evidence)
|
|
7
|
+
* - memory_add: add a new memory entry
|
|
8
|
+
* - memory_remove: remove an outdated or incorrect memory entry
|
|
9
|
+
* - memory_topics: list all memory topic files with hotness scores
|
|
10
|
+
*
|
|
11
|
+
* Architecture inspired by OpenHuman (https://github.com/tinyhumansai/openhuman).
|
|
12
|
+
* Clean-room TypeScript implementation under MIT — not a code translation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
loadScores,
|
|
21
|
+
saveScores,
|
|
22
|
+
entryHash,
|
|
23
|
+
reinforce,
|
|
24
|
+
getActiveEntries,
|
|
25
|
+
PINNED_SCORE,
|
|
26
|
+
type ScoredEntry,
|
|
27
|
+
} from "./memory-scoring.js";
|
|
28
|
+
import { listTopics, readAllTopicEntries, CATEGORY_TO_TOPIC, MAX_TOPIC_CHARS, type TopicInfo } from "./memory-tree.js";
|
|
29
|
+
import { embedEntry, removeEmbedding, vectorSearch, embedMissing } from "./memory-embeddings.js";
|
|
30
|
+
import { maybePromoteAfterReinforce } from "./memory-promotion.js";
|
|
31
|
+
|
|
32
|
+
const NEAR_DUPLICATE_THRESHOLD = 0.90;
|
|
33
|
+
|
|
34
|
+
// ── memory_search ────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
function registerMemorySearch(pi: ExtensionAPI, state: { embeddingsMigrated: boolean }): void {
|
|
37
|
+
pi.registerTool({
|
|
38
|
+
name: "memory_search",
|
|
39
|
+
label: "Memory Search",
|
|
40
|
+
description:
|
|
41
|
+
"Search project memories by keyword. Use before answering questions about " +
|
|
42
|
+
"prior sessions, user preferences, past decisions, or recurring patterns. " +
|
|
43
|
+
"Returns matching entries with their scores and categories.",
|
|
44
|
+
parameters: Type.Object({
|
|
45
|
+
query: Type.String({ description: "Search keyword or phrase" }),
|
|
46
|
+
category: Type.Optional(
|
|
47
|
+
Type.String({
|
|
48
|
+
description: "Filter by category: preference, lesson, pattern, decision, done, mistake",
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
}),
|
|
52
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
53
|
+
const cwd = ctx.cwd;
|
|
54
|
+
|
|
55
|
+
// Read from topics (source of truth)
|
|
56
|
+
const topicEntries = readAllTopicEntries(cwd);
|
|
57
|
+
|
|
58
|
+
// Lazy migration: embed any entries missing from the vector store
|
|
59
|
+
if (!state.embeddingsMigrated) {
|
|
60
|
+
try {
|
|
61
|
+
await embedMissing(cwd, topicEntries);
|
|
62
|
+
state.embeddingsMigrated = true; // Only mark done on success
|
|
63
|
+
} catch {
|
|
64
|
+
console.debug("[memory] embedding migration failed, will retry next search");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const scores = loadScores(cwd);
|
|
68
|
+
const queryLower = params.query.toLowerCase();
|
|
69
|
+
|
|
70
|
+
// Build searchable entries from topics
|
|
71
|
+
const searchEntries = topicEntries.map((te) => ({
|
|
72
|
+
text: te.text,
|
|
73
|
+
category: te.category,
|
|
74
|
+
pinned: te.pinned,
|
|
75
|
+
fullLine: `- [${te.category}] ${te.text}`,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
if (searchEntries.length === 0) {
|
|
79
|
+
return { content: [{ type: "text", text: "No memories found." }] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Keyword substring search (existing behavior)
|
|
83
|
+
const keywordMatches = searchEntries
|
|
84
|
+
.filter((entry) => {
|
|
85
|
+
if (params.category && entry.category !== params.category) return false;
|
|
86
|
+
return entry.text.toLowerCase().includes(queryLower) ||
|
|
87
|
+
entry.category.includes(queryLower);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Vector similarity search (additive — merges with keyword results)
|
|
91
|
+
const vectorMatches = await vectorSearch(
|
|
92
|
+
cwd,
|
|
93
|
+
params.query,
|
|
94
|
+
searchEntries.filter((e) => !params.category || e.category === params.category),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Merge: union of keyword + vector results, deduplicated by text
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
const merged: { text: string; category: string; pinned: boolean; similarity?: number }[] = [];
|
|
100
|
+
|
|
101
|
+
// Add vector results first (sorted by similarity)
|
|
102
|
+
for (const vm of vectorMatches) {
|
|
103
|
+
const dedupKey = `${vm.category}:${vm.text}`;
|
|
104
|
+
if (!seen.has(dedupKey)) {
|
|
105
|
+
seen.add(dedupKey);
|
|
106
|
+
merged.push(vm);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Add keyword-only results (not already in vector results)
|
|
110
|
+
for (const km of keywordMatches) {
|
|
111
|
+
const dedupKey = `${km.category}:${km.text}`;
|
|
112
|
+
if (!seen.has(dedupKey)) {
|
|
113
|
+
seen.add(dedupKey);
|
|
114
|
+
merged.push({ text: km.text, category: km.category, pinned: km.pinned });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const results = merged
|
|
119
|
+
.map((entry) => {
|
|
120
|
+
const canonLine = `- [${entry.category}] ${entry.text}`;
|
|
121
|
+
const hash = entryHash(canonLine);
|
|
122
|
+
const scored = scores.entries[hash];
|
|
123
|
+
return {
|
|
124
|
+
text: entry.text,
|
|
125
|
+
category: entry.category,
|
|
126
|
+
pinned: entry.pinned,
|
|
127
|
+
score: scored?.score ?? 0,
|
|
128
|
+
lifecycle: scored?.lifecycle ?? "unknown",
|
|
129
|
+
evidenceCount: scored?.evidenceCount ?? 0,
|
|
130
|
+
lastReinforced: scored?.lastReinforced ?? "unknown",
|
|
131
|
+
similarity: entry.similarity,
|
|
132
|
+
};
|
|
133
|
+
})
|
|
134
|
+
// Sort: pinned first, then by combined rank (similarity + stability score)
|
|
135
|
+
.sort((a, b) => {
|
|
136
|
+
if (a.pinned && !b.pinned) return -1;
|
|
137
|
+
if (!a.pinned && b.pinned) return 1;
|
|
138
|
+
const aRank = (a.similarity ?? 0) * 100 + (a.score ?? 0);
|
|
139
|
+
const bRank = (b.similarity ?? 0) * 100 + (b.score ?? 0);
|
|
140
|
+
return bRank - aRank;
|
|
141
|
+
})
|
|
142
|
+
.slice(0, 30);
|
|
143
|
+
|
|
144
|
+
if (results.length === 0) {
|
|
145
|
+
return {
|
|
146
|
+
content: [{ type: "text", text: `No memories found matching "${params.query}".` }],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const lines = results.map((r) => {
|
|
151
|
+
const pin = r.pinned ? " (pinned)" : "";
|
|
152
|
+
const sim = r.similarity !== undefined ? `, similarity: ${r.similarity.toFixed(3)}` : "";
|
|
153
|
+
const scored = scores.entries[entryHash(`- [${r.category}] ${r.text}`)];
|
|
154
|
+
const provParts: string[] = [];
|
|
155
|
+
if (scored?.sourceSession) provParts.push(`session: ${scored.sourceSession}`);
|
|
156
|
+
if (scored?.derivedFrom) provParts.push(`from: ${scored.derivedFrom}`);
|
|
157
|
+
if (scored?.informs?.length) provParts.push(`informs: ${scored.informs.join(", ")}`);
|
|
158
|
+
const prov = provParts.length ? ` [${provParts.join("; ")}]` : "";
|
|
159
|
+
return `- [${r.category}] ${r.text}${pin} — score: ${typeof r.score === "number" ? r.score.toFixed(2) : r.score}, evidence: ${r.evidenceCount}${sim}${prov}`;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const text = `Found ${results.length} memories matching "${params.query}":\n\n${lines.join("\n")}`;
|
|
163
|
+
return { content: [{ type: "text", text }] };
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── memory_reinforce ─────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function registerMemoryReinforce(pi: ExtensionAPI): void {
|
|
171
|
+
pi.registerTool({
|
|
172
|
+
name: "memory_reinforce",
|
|
173
|
+
label: "Memory Reinforce",
|
|
174
|
+
description:
|
|
175
|
+
"Reinforce an existing memory entry — call this when you notice a memory " +
|
|
176
|
+
"is relevant to the current task. Bumps the evidence count and refreshes " +
|
|
177
|
+
"the last-reinforced timestamp, increasing its stability score.",
|
|
178
|
+
parameters: Type.Object({
|
|
179
|
+
entryText: Type.String({
|
|
180
|
+
description: "The exact text of the memory entry to reinforce (without category prefix)",
|
|
181
|
+
}),
|
|
182
|
+
category: Type.String({
|
|
183
|
+
description: "Category: preference, lesson, pattern, decision, done, mistake",
|
|
184
|
+
}),
|
|
185
|
+
}),
|
|
186
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
187
|
+
const entryLine = `- [${params.category}] ${params.entryText}`;
|
|
188
|
+
const reinforced = reinforce(ctx.cwd, entryLine);
|
|
189
|
+
|
|
190
|
+
if (reinforced) {
|
|
191
|
+
const scores = loadScores(ctx.cwd);
|
|
192
|
+
const hash = entryHash(entryLine);
|
|
193
|
+
const entry = scores.entries[hash];
|
|
194
|
+
let promoNote = "";
|
|
195
|
+
try {
|
|
196
|
+
const promo = maybePromoteAfterReinforce(ctx.cwd, entryLine);
|
|
197
|
+
if (promo && (promo.applied > 0 || promo.queued > 0)) {
|
|
198
|
+
promoNote = `\nPromotion pass: applied=${promo.applied}, proposed=${promo.queued}` +
|
|
199
|
+
(promo.details.length ? ` (${promo.details.join("; ")})` : "");
|
|
200
|
+
}
|
|
201
|
+
} catch (e: any) {
|
|
202
|
+
console.debug("[memory] promote-after-reinforce failed:", e?.message || e);
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
content: [{
|
|
206
|
+
type: "text",
|
|
207
|
+
text: `Reinforced: [${params.category}] ${params.entryText}\nNew evidence count: ${entry?.evidenceCount ?? "?"}, score: ${entry?.score?.toFixed(2) ?? "?"}${promoNote}`,
|
|
208
|
+
}],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
content: [{
|
|
214
|
+
type: "text",
|
|
215
|
+
text: `Memory not found: [${params.category}] ${params.entryText}\nUse memory_search to find the exact text.`,
|
|
216
|
+
}],
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── memory_topics ────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
function registerMemoryTopics(pi: ExtensionAPI): void {
|
|
225
|
+
pi.registerTool({
|
|
226
|
+
name: "memory_topics",
|
|
227
|
+
label: "Memory Topics",
|
|
228
|
+
description:
|
|
229
|
+
"List all memory topic files with their hotness scores and entry counts. " +
|
|
230
|
+
"Use to understand what the project memory covers and which topics are most active.",
|
|
231
|
+
parameters: Type.Object({}),
|
|
232
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
233
|
+
const topics = listTopics(ctx.cwd);
|
|
234
|
+
|
|
235
|
+
if (topics.length === 0) {
|
|
236
|
+
return { content: [{ type: "text", text: "No memory topics found." }] };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const lines = topics.map((t: TopicInfo) =>
|
|
240
|
+
`- **${t.name}**: ${t.entryCount} entries, hotness: ${t.hotness.toFixed(1)}, ${(t.chars / 4).toFixed(0)} tokens`
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const text = `Memory topics (${topics.length}):\n\n${lines.join("\n")}`;
|
|
244
|
+
return { content: [{ type: "text", text }] };
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── memory_add ──────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
function registerMemoryAdd(pi: ExtensionAPI): void {
|
|
252
|
+
pi.registerTool({
|
|
253
|
+
name: "memory_add",
|
|
254
|
+
label: "Memory Add",
|
|
255
|
+
description:
|
|
256
|
+
"Add a new memory entry. Use proactively when you learn something worth " +
|
|
257
|
+
"remembering: user preferences, environment facts, corrections, conventions, " +
|
|
258
|
+
"completed work. Keep entries short (one line, ~100 chars max), specific, and actionable.",
|
|
259
|
+
parameters: Type.Object({
|
|
260
|
+
text: Type.String({ description: "The memory entry text (short, specific, actionable)" }),
|
|
261
|
+
category: Type.String({
|
|
262
|
+
description: "Category: preference, lesson, pattern, decision, done, mistake",
|
|
263
|
+
}),
|
|
264
|
+
pinned: Type.Optional(
|
|
265
|
+
Type.Boolean({
|
|
266
|
+
description: "Pin this memory (never decays, never removed by dreaming). Only when user explicitly says 'remember this'.",
|
|
267
|
+
}),
|
|
268
|
+
),
|
|
269
|
+
trigger: Type.Optional(
|
|
270
|
+
Type.String({
|
|
271
|
+
description: "Enforcement trigger (e.g., 'bash_contains git add .', 'tool_name write', 'file_modified *.py'). When set, this memory becomes code-enforced.",
|
|
272
|
+
}),
|
|
273
|
+
),
|
|
274
|
+
action: Type.Optional(
|
|
275
|
+
Type.String({
|
|
276
|
+
description: "Enforcement action: 'block' (prevent), 'run_after <command>' (execute after), 'warn' (append warning)",
|
|
277
|
+
}),
|
|
278
|
+
),
|
|
279
|
+
verifier: Type.Optional(
|
|
280
|
+
Type.String({
|
|
281
|
+
description: "Semantic verifier condition (e.g., 'tool_called ask_user before gh pr merge'). Checked at turn_end.",
|
|
282
|
+
}),
|
|
283
|
+
),
|
|
284
|
+
sourceSession: Type.Optional(
|
|
285
|
+
Type.String({
|
|
286
|
+
description: "Optional provenance: session id or basename this memory came from",
|
|
287
|
+
}),
|
|
288
|
+
),
|
|
289
|
+
derivedFrom: Type.Optional(
|
|
290
|
+
Type.String({
|
|
291
|
+
description: "Optional provenance: short note or prior entry this was derived from",
|
|
292
|
+
}),
|
|
293
|
+
),
|
|
294
|
+
informs: Type.Optional(
|
|
295
|
+
Type.Array(Type.String(), {
|
|
296
|
+
description: "Optional provenance: what this memory informs (e.g. pr-review, git, skill name)",
|
|
297
|
+
}),
|
|
298
|
+
),
|
|
299
|
+
}),
|
|
300
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
301
|
+
const cwd = ctx.cwd;
|
|
302
|
+
const category = params.category;
|
|
303
|
+
const text = params.text;
|
|
304
|
+
const isPinned = params.pinned ?? false;
|
|
305
|
+
|
|
306
|
+
// Validate category
|
|
307
|
+
const validCategories = ["preference", "lesson", "pattern", "decision", "done", "mistake"];
|
|
308
|
+
if (!validCategories.includes(category)) {
|
|
309
|
+
return { content: [{ type: "text", text: `Invalid category "${category}". Valid: ${validCategories.join(", ")}` }] };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Validate enforcement parameters
|
|
313
|
+
if (params.trigger) {
|
|
314
|
+
const validPrefixes = ["bash_contains ", "bash_regex ", "tool_name ", "file_modified "];
|
|
315
|
+
if (!validPrefixes.some(p => params.trigger!.startsWith(p))) {
|
|
316
|
+
return {
|
|
317
|
+
content: [{
|
|
318
|
+
type: "text",
|
|
319
|
+
text: `Invalid trigger format "${params.trigger}". Must start with: ${validPrefixes.map(p => p.trim()).join(", ")}`,
|
|
320
|
+
}],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (params.action) {
|
|
325
|
+
const validActions = ["block", "warn"];
|
|
326
|
+
const isRunAfter = params.action.startsWith("run_after ");
|
|
327
|
+
if (!validActions.includes(params.action) && !isRunAfter) {
|
|
328
|
+
return {
|
|
329
|
+
content: [{
|
|
330
|
+
type: "text",
|
|
331
|
+
text: `Invalid action "${params.action}". Valid: block, warn, run_after <command>`,
|
|
332
|
+
}],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (isRunAfter && !params.action.slice("run_after ".length).trim()) {
|
|
336
|
+
return {
|
|
337
|
+
content: [{
|
|
338
|
+
type: "text",
|
|
339
|
+
text: `Invalid action "${params.action}". run_after requires a command (e.g., 'run_after .dev/deploy-all.sh')`,
|
|
340
|
+
}],
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Canonical line (no markers) — used for hashing/scoring
|
|
346
|
+
const canonicalLine = `- [${category}] ${text}`;
|
|
347
|
+
// File line — includes markers for display
|
|
348
|
+
const isEnforced = !!((params.trigger && params.action) || params.verifier);
|
|
349
|
+
let entryLine = canonicalLine;
|
|
350
|
+
if (isPinned && isEnforced) entryLine = `- [${category}] ${text} *(pinned)* *(enforced)*`;
|
|
351
|
+
else if (isPinned) entryLine = `- [${category}] ${text} *(pinned)*`;
|
|
352
|
+
else if (isEnforced) entryLine = `- [${category}] ${text} *(enforced)*`;
|
|
353
|
+
|
|
354
|
+
// Check for duplicates — vector similarity first, then exact match
|
|
355
|
+
const topicEntries = readAllTopicEntries(cwd);
|
|
356
|
+
|
|
357
|
+
// Check for near-duplicates via vector similarity (catches same lesson with different wording)
|
|
358
|
+
try {
|
|
359
|
+
const seen = new Set<string>();
|
|
360
|
+
const sameCategoryEntries = topicEntries.filter(te => {
|
|
361
|
+
if (te.category !== category || seen.has(te.text)) return false;
|
|
362
|
+
seen.add(te.text);
|
|
363
|
+
return true;
|
|
364
|
+
});
|
|
365
|
+
await embedMissing(cwd, sameCategoryEntries);
|
|
366
|
+
// Do NOT embed the new entry before searching — if the entry already exists
|
|
367
|
+
// in sameCategoryEntries, it would match itself with similarity 1.000.
|
|
368
|
+
// embedEntry() is called later after confirming no duplicate.
|
|
369
|
+
const vectorMatches = await vectorSearch(cwd, text, sameCategoryEntries, 20);
|
|
370
|
+
for (const vm of vectorMatches) {
|
|
371
|
+
if (vm.similarity >= NEAR_DUPLICATE_THRESHOLD) {
|
|
372
|
+
const existingLine = `- [${vm.category}] ${vm.text}`;
|
|
373
|
+
if (reinforce(cwd, existingLine)) {
|
|
374
|
+
// Merge enforcement fields into existing entry if new entry has them
|
|
375
|
+
let merged = false;
|
|
376
|
+
if (params.trigger || params.action || params.verifier) {
|
|
377
|
+
const scores = loadScores(cwd);
|
|
378
|
+
const existingHash = entryHash(existingLine);
|
|
379
|
+
const existingEntry = scores.entries[existingHash];
|
|
380
|
+
if (existingEntry) {
|
|
381
|
+
if (params.trigger) {
|
|
382
|
+
existingEntry.trigger = params.trigger as any;
|
|
383
|
+
}
|
|
384
|
+
if (params.action) {
|
|
385
|
+
if (params.action.startsWith("run_after ")) {
|
|
386
|
+
existingEntry.action = "run_after";
|
|
387
|
+
existingEntry.actionCommand = params.action.slice("run_after ".length);
|
|
388
|
+
} else if (params.action === "block" || params.action === "warn") {
|
|
389
|
+
existingEntry.action = params.action;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (params.verifier) {
|
|
393
|
+
existingEntry.verifier = params.verifier;
|
|
394
|
+
}
|
|
395
|
+
saveScores(cwd, scores);
|
|
396
|
+
merged = true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// No removeEmbedding needed — new entry was not embedded before searching
|
|
400
|
+
const enforcementNote = merged ? " (enforcement fields merged)" : "";
|
|
401
|
+
return {
|
|
402
|
+
content: [{
|
|
403
|
+
type: "text",
|
|
404
|
+
text: `Near-duplicate found (similarity: ${vm.similarity.toFixed(3)}) — reinforced instead: [${vm.category}] ${vm.text}${enforcementNote}`,
|
|
405
|
+
}],
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
// reinforce() failed (score entry missing) — try next candidate
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
} catch (err) {
|
|
413
|
+
console.debug(`[memory] memory_add: vector dedup skipped: ${err}`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Exact match check (fast O(n) string comparison after expensive vector check)
|
|
417
|
+
for (const te of topicEntries) {
|
|
418
|
+
if (te.text === text && te.category === category) {
|
|
419
|
+
// Reinforce instead of duplicating — always use canonical line (no pinned marker)
|
|
420
|
+
reinforce(cwd, canonicalLine);
|
|
421
|
+
// Merge enforcement fields into existing entry if new entry has them
|
|
422
|
+
let merged = false;
|
|
423
|
+
if (params.trigger || params.action || params.verifier) {
|
|
424
|
+
const scores = loadScores(cwd);
|
|
425
|
+
const existingHash = entryHash(canonicalLine);
|
|
426
|
+
const existingEntry = scores.entries[existingHash];
|
|
427
|
+
if (existingEntry) {
|
|
428
|
+
if (params.trigger) {
|
|
429
|
+
existingEntry.trigger = params.trigger as any;
|
|
430
|
+
}
|
|
431
|
+
if (params.action) {
|
|
432
|
+
if (params.action.startsWith("run_after ")) {
|
|
433
|
+
existingEntry.action = "run_after";
|
|
434
|
+
existingEntry.actionCommand = params.action.slice("run_after ".length);
|
|
435
|
+
} else if (params.action === "block" || params.action === "warn") {
|
|
436
|
+
existingEntry.action = params.action;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (params.verifier) {
|
|
440
|
+
existingEntry.verifier = params.verifier;
|
|
441
|
+
}
|
|
442
|
+
saveScores(cwd, scores);
|
|
443
|
+
merged = true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// No removeEmbedding here — same text/category key as existing entry, embedding is still valid
|
|
447
|
+
const enforcementNote = merged ? " (enforcement fields merged)" : "";
|
|
448
|
+
return { content: [{ type: "text", text: `Already exists — reinforced instead: [${category}] ${text}${enforcementNote}` }] };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const topicName = CATEGORY_TO_TOPIC[category as keyof typeof CATEGORY_TO_TOPIC];
|
|
453
|
+
const topicsDir = join(cwd, ".pi", "memory", "topics");
|
|
454
|
+
const topicPath = join(topicsDir, `${topicName}.md`);
|
|
455
|
+
|
|
456
|
+
// Ensure directory exists
|
|
457
|
+
if (!existsSync(topicsDir)) {
|
|
458
|
+
mkdirSync(topicsDir, { recursive: true });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Read or create topic file
|
|
462
|
+
let content = "";
|
|
463
|
+
if (existsSync(topicPath)) {
|
|
464
|
+
content = readFileSync(topicPath, "utf-8");
|
|
465
|
+
} else {
|
|
466
|
+
const title = topicName.replace(/-/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase());
|
|
467
|
+
content = `# ${title}\n`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Check topic size cap before writing
|
|
471
|
+
const newContent = content.trimEnd() + "\n" + entryLine + "\n";
|
|
472
|
+
if (newContent.length > MAX_TOPIC_CHARS) {
|
|
473
|
+
return {
|
|
474
|
+
content: [{
|
|
475
|
+
type: "text",
|
|
476
|
+
text: `Topic "${topicName}" would exceed size limit (${newContent.length}/${MAX_TOPIC_CHARS} chars). Consolidate or remove entries first using memory_remove.`,
|
|
477
|
+
}],
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Append entry
|
|
482
|
+
content = newContent;
|
|
483
|
+
writeFileSync(topicPath, content, "utf-8");
|
|
484
|
+
|
|
485
|
+
// Add score entry — always hash the canonical line (no pinned marker)
|
|
486
|
+
const scores = loadScores(cwd);
|
|
487
|
+
const hash = entryHash(canonicalLine);
|
|
488
|
+
const entry: ScoredEntry = {
|
|
489
|
+
class: category,
|
|
490
|
+
score: isPinned ? PINNED_SCORE : 1.0,
|
|
491
|
+
evidenceCount: 1,
|
|
492
|
+
cue: "explicit",
|
|
493
|
+
firstSeen: new Date().toISOString(),
|
|
494
|
+
lastReinforced: new Date().toISOString(),
|
|
495
|
+
userState: isPinned ? "pinned" : "auto",
|
|
496
|
+
lifecycle: "active",
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
// Add enforcement fields if provided
|
|
500
|
+
if (params.trigger) {
|
|
501
|
+
entry.trigger = params.trigger as any;
|
|
502
|
+
// Parse action — "run_after <cmd>" splits into action + actionCommand
|
|
503
|
+
if (params.action) {
|
|
504
|
+
if (params.action.startsWith("run_after ")) {
|
|
505
|
+
entry.action = "run_after";
|
|
506
|
+
entry.actionCommand = params.action.slice("run_after ".length);
|
|
507
|
+
} else if (params.action === "block" || params.action === "warn") {
|
|
508
|
+
entry.action = params.action;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (params.verifier) {
|
|
513
|
+
entry.verifier = params.verifier;
|
|
514
|
+
}
|
|
515
|
+
if (params.sourceSession) entry.sourceSession = params.sourceSession;
|
|
516
|
+
if (params.derivedFrom) entry.derivedFrom = params.derivedFrom;
|
|
517
|
+
if (params.informs && params.informs.length > 0) entry.informs = params.informs;
|
|
518
|
+
|
|
519
|
+
scores.entries[hash] = entry;
|
|
520
|
+
saveScores(cwd, scores);
|
|
521
|
+
|
|
522
|
+
// Embed the new entry for future similarity searches
|
|
523
|
+
await embedEntry(cwd, text, category);
|
|
524
|
+
|
|
525
|
+
const pin = isPinned ? " (pinned)" : "";
|
|
526
|
+
return {
|
|
527
|
+
content: [{ type: "text", text: `Added: [${category}] ${text}${pin}` }],
|
|
528
|
+
};
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ── memory_remove ────────────────────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
function registerMemoryRemove(pi: ExtensionAPI): void {
|
|
536
|
+
pi.registerTool({
|
|
537
|
+
name: "memory_remove",
|
|
538
|
+
label: "Memory Remove",
|
|
539
|
+
description:
|
|
540
|
+
"Remove a memory entry that is no longer relevant or accurate. " +
|
|
541
|
+
"Use when information is outdated, wrong, or superseded by a newer entry.",
|
|
542
|
+
parameters: Type.Object({
|
|
543
|
+
text: Type.String({
|
|
544
|
+
description: "The exact text of the memory entry to remove (without category prefix)",
|
|
545
|
+
}),
|
|
546
|
+
category: Type.String({
|
|
547
|
+
description: "Category: preference, lesson, pattern, decision, done, mistake",
|
|
548
|
+
}),
|
|
549
|
+
}),
|
|
550
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
551
|
+
const cwd = ctx.cwd;
|
|
552
|
+
const category = params.category;
|
|
553
|
+
const text = params.text;
|
|
554
|
+
|
|
555
|
+
// Validate category
|
|
556
|
+
const validCategories = ["preference", "lesson", "pattern", "decision", "done", "mistake"];
|
|
557
|
+
if (!validCategories.includes(category)) {
|
|
558
|
+
return { content: [{ type: "text", text: `Invalid category "${category}". Valid: ${validCategories.join(", ")}` }] };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const topicName = CATEGORY_TO_TOPIC[category as keyof typeof CATEGORY_TO_TOPIC];
|
|
562
|
+
const topicPath = join(cwd, ".pi", "memory", "topics", `${topicName}.md`);
|
|
563
|
+
|
|
564
|
+
if (!existsSync(topicPath)) {
|
|
565
|
+
return { content: [{ type: "text", text: `Topic file not found for category "${category}".` }] };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const content = readFileSync(topicPath, "utf-8");
|
|
569
|
+
const pinnedEnforcedLine = `- [${category}] ${text} *(pinned)* *(enforced)*`;
|
|
570
|
+
const pinnedLine = `- [${category}] ${text} *(pinned)*`;
|
|
571
|
+
const enforcedLine = `- [${category}] ${text} *(enforced)*`;
|
|
572
|
+
const learnedLine = `- [${category}] ${text}`;
|
|
573
|
+
|
|
574
|
+
// Find which line matches
|
|
575
|
+
const lines = content.split("\n");
|
|
576
|
+
let removedLine: string | null = null;
|
|
577
|
+
for (const candidate of [pinnedEnforcedLine, pinnedLine, enforcedLine, learnedLine]) {
|
|
578
|
+
if (lines.some(l => l.trimEnd() === candidate)) {
|
|
579
|
+
removedLine = candidate;
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (!removedLine) {
|
|
585
|
+
return { content: [{ type: "text", text: `Memory not found: [${category}] ${text}\nUse memory_search to find the exact text.` }] };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Remove the line and collapse consecutive blank lines
|
|
589
|
+
const newLines = lines.filter(l => l.trimEnd() !== removedLine);
|
|
590
|
+
const cleaned = newLines.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
591
|
+
writeFileSync(topicPath, cleaned, "utf-8");
|
|
592
|
+
|
|
593
|
+
// Remove embedding
|
|
594
|
+
removeEmbedding(cwd, text, category);
|
|
595
|
+
|
|
596
|
+
// Clean up scores — always use canonical line for hash (no pinned marker)
|
|
597
|
+
const scores = loadScores(cwd);
|
|
598
|
+
const canonicalLine = `- [${category}] ${text}`;
|
|
599
|
+
const hash = entryHash(canonicalLine);
|
|
600
|
+
if (scores.entries[hash]) {
|
|
601
|
+
delete scores.entries[hash];
|
|
602
|
+
saveScores(cwd, scores);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
content: [{ type: "text", text: `Removed: [${category}] ${text}` }],
|
|
607
|
+
};
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ── memory_reflect ──────────────────────────────────────────────────
|
|
613
|
+
|
|
614
|
+
function registerMemoryReflect(pi: ExtensionAPI): void {
|
|
615
|
+
pi.registerTool({
|
|
616
|
+
name: "memory_reflect",
|
|
617
|
+
label: "Memory Reflect",
|
|
618
|
+
description:
|
|
619
|
+
"Synthesize an answer from long-term memory. Instead of returning raw entries, " +
|
|
620
|
+
"this tool searches memory and produces a coherent summary answering the question. " +
|
|
621
|
+
"Use when you need to understand a pattern, process, or context from past sessions.",
|
|
622
|
+
parameters: Type.Object({
|
|
623
|
+
query: Type.String({ description: "Question to answer from memory" }),
|
|
624
|
+
}),
|
|
625
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
626
|
+
const cwd = ctx.cwd;
|
|
627
|
+
const topicEntries = readAllTopicEntries(cwd);
|
|
628
|
+
if (topicEntries.length === 0) {
|
|
629
|
+
return { content: [{ type: "text", text: "No memories to reflect on." }] };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Get relevant memories via vector + keyword search
|
|
633
|
+
const vectorMatches = await vectorSearch(cwd, params.query, topicEntries, 10);
|
|
634
|
+
const queryLower = params.query.toLowerCase();
|
|
635
|
+
const keywordMatches = topicEntries.filter(e =>
|
|
636
|
+
e.text.toLowerCase().includes(queryLower)
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
// Merge and deduplicate
|
|
640
|
+
const seen = new Set<string>();
|
|
641
|
+
const relevant: { text: string; category: string; similarity?: number }[] = [];
|
|
642
|
+
for (const vm of vectorMatches) {
|
|
643
|
+
const dedupKey = `${vm.category}:${vm.text}`;
|
|
644
|
+
if (!seen.has(dedupKey)) { seen.add(dedupKey); relevant.push(vm); }
|
|
645
|
+
}
|
|
646
|
+
for (const km of keywordMatches) {
|
|
647
|
+
const dedupKey = `${km.category}:${km.text}`;
|
|
648
|
+
if (!seen.has(dedupKey)) { seen.add(dedupKey); relevant.push({ text: km.text, category: km.category }); }
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (relevant.length === 0) {
|
|
652
|
+
return { content: [{ type: "text", text: `No relevant memories found for: "${params.query}"` }] };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Format as structured context for the AI to synthesize (include provenance when present)
|
|
656
|
+
const scores = loadScores(cwd);
|
|
657
|
+
const memoryContext = relevant
|
|
658
|
+
.slice(0, 15)
|
|
659
|
+
.map(r => {
|
|
660
|
+
const sim = r.similarity !== undefined ? ` (${r.similarity.toFixed(3)})` : "";
|
|
661
|
+
const scored = scores.entries[entryHash(`- [${r.category}] ${r.text}`)];
|
|
662
|
+
const provParts: string[] = [];
|
|
663
|
+
if (scored?.sourceSession) provParts.push(`session: ${scored.sourceSession}`);
|
|
664
|
+
if (scored?.derivedFrom) provParts.push(`from: ${scored.derivedFrom}`);
|
|
665
|
+
if (scored?.informs?.length) provParts.push(`informs: ${scored.informs.join(", ")}`);
|
|
666
|
+
const prov = provParts.length ? ` [${provParts.join("; ")}]` : "";
|
|
667
|
+
return `- [${r.category}] ${r.text}${sim}${prov}`;
|
|
668
|
+
})
|
|
669
|
+
.join("\n");
|
|
670
|
+
|
|
671
|
+
const text = `## Recalled memories for: "${params.query}"\n\n${memoryContext}\n\n` +
|
|
672
|
+
`*${relevant.length} memories found. Synthesize these into a coherent answer.*`;
|
|
673
|
+
return { content: [{ type: "text", text }] };
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ── memory_edit ─────────────────────────────────────────────────────
|
|
679
|
+
|
|
680
|
+
function registerMemoryEdit(pi: ExtensionAPI): void {
|
|
681
|
+
pi.registerTool({
|
|
682
|
+
name: "memory_edit",
|
|
683
|
+
label: "Memory Edit",
|
|
684
|
+
description:
|
|
685
|
+
"Update, invalidate, or supersede a memory entry in-place. " +
|
|
686
|
+
"Use 'update' to change content, 'invalidate' to mark as superseded by another entry. " +
|
|
687
|
+
"More precise than remove+add — preserves scoring history.",
|
|
688
|
+
parameters: Type.Object({
|
|
689
|
+
op: Type.Union([
|
|
690
|
+
Type.Literal("update"),
|
|
691
|
+
Type.Literal("invalidate"),
|
|
692
|
+
], { description: "Operation: update (change content) or invalidate (supersede)" }),
|
|
693
|
+
text: Type.String({ description: "Exact text of the memory entry to edit" }),
|
|
694
|
+
category: Type.String({ description: "Category: preference, lesson, pattern, decision, done, mistake" }),
|
|
695
|
+
newText: Type.Optional(Type.String({ description: "New text content (for update)" })),
|
|
696
|
+
supersededBy: Type.Optional(Type.String({ description: "Text of the replacement entry (for invalidate)" })),
|
|
697
|
+
sourceSession: Type.Optional(Type.String({ description: "Optional provenance: session id" })),
|
|
698
|
+
derivedFrom: Type.Optional(Type.String({ description: "Optional provenance: derived-from note" })),
|
|
699
|
+
informs: Type.Optional(Type.Array(Type.String(), { description: "Optional provenance: informs targets" })),
|
|
700
|
+
}),
|
|
701
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
702
|
+
const cwd = ctx.cwd;
|
|
703
|
+
const { op, text, category, newText, supersededBy } = params;
|
|
704
|
+
|
|
705
|
+
const validCategories = ["preference", "lesson", "pattern", "decision", "done", "mistake"];
|
|
706
|
+
if (!validCategories.includes(category)) {
|
|
707
|
+
return { content: [{ type: "text", text: `Invalid category "${category}". Valid: ${validCategories.join(", ")}` }] };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const topicName = CATEGORY_TO_TOPIC[category as keyof typeof CATEGORY_TO_TOPIC];
|
|
711
|
+
const topicPath = join(cwd, ".pi", "memory", "topics", `${topicName}.md`);
|
|
712
|
+
|
|
713
|
+
if (!existsSync(topicPath)) {
|
|
714
|
+
return { content: [{ type: "text", text: `Topic file not found for category "${category}".` }] };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const content = readFileSync(topicPath, "utf-8");
|
|
718
|
+
const oldLine = `- [${category}] ${text}`;
|
|
719
|
+
const oldLinePinnedEnforced = `- [${category}] ${text} *(pinned)* *(enforced)*`;
|
|
720
|
+
const oldLinePinned = `- [${category}] ${text} *(pinned)*`;
|
|
721
|
+
const oldLineEnforced = `- [${category}] ${text} *(enforced)*`;
|
|
722
|
+
|
|
723
|
+
// Find the matching line
|
|
724
|
+
const lines = content.split("\n");
|
|
725
|
+
let matchIndex = -1;
|
|
726
|
+
let wasPinned = false;
|
|
727
|
+
let wasEnforced = false;
|
|
728
|
+
for (let i = 0; i < lines.length; i++) {
|
|
729
|
+
const trimmed = lines[i].trimEnd();
|
|
730
|
+
if (trimmed === oldLinePinnedEnforced) { matchIndex = i; wasPinned = true; wasEnforced = true; break; }
|
|
731
|
+
if (trimmed === oldLinePinned) { matchIndex = i; wasPinned = true; break; }
|
|
732
|
+
if (trimmed === oldLineEnforced) { matchIndex = i; wasEnforced = true; break; }
|
|
733
|
+
if (trimmed === oldLine) { matchIndex = i; break; }
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (matchIndex === -1) {
|
|
737
|
+
return { content: [{ type: "text", text: `Memory not found: [${category}] ${text}\nUse memory_search to find the exact text.` }] };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (op === "update") {
|
|
741
|
+
if (!newText) {
|
|
742
|
+
return { content: [{ type: "text", text: "newText is required for update operation." }] };
|
|
743
|
+
}
|
|
744
|
+
// Enforce single-line content
|
|
745
|
+
if (newText.includes("\n")) {
|
|
746
|
+
return { content: [{ type: "text", text: "newText must be a single line (no newlines)." }] };
|
|
747
|
+
}
|
|
748
|
+
// Check topic size cap
|
|
749
|
+
const currentContent = readFileSync(topicPath, "utf-8");
|
|
750
|
+
const markers = `${wasPinned ? " *(pinned)*" : ""}${wasEnforced ? " *(enforced)*" : ""}`;
|
|
751
|
+
const newContent = currentContent.replace(lines[matchIndex], `- [${category}] ${newText}${markers}`);
|
|
752
|
+
if (newContent.length > MAX_TOPIC_CHARS) {
|
|
753
|
+
return { content: [{ type: "text", text: `Update would exceed topic size limit (${newContent.length}/${MAX_TOPIC_CHARS} chars).` }] };
|
|
754
|
+
}
|
|
755
|
+
// Replace the line
|
|
756
|
+
lines[matchIndex] = `- [${category}] ${newText}${markers}`;
|
|
757
|
+
writeFileSync(topicPath, lines.join("\n"), "utf-8");
|
|
758
|
+
|
|
759
|
+
// Update scores: transfer old entry's score to new entry
|
|
760
|
+
const scores = loadScores(cwd);
|
|
761
|
+
const oldHash = entryHash(`- [${category}] ${text}`);
|
|
762
|
+
const newHash = entryHash(`- [${category}] ${newText}`);
|
|
763
|
+
if (scores.entries[oldHash]) {
|
|
764
|
+
scores.entries[newHash] = { ...scores.entries[oldHash] };
|
|
765
|
+
delete scores.entries[oldHash];
|
|
766
|
+
}
|
|
767
|
+
const target = scores.entries[newHash];
|
|
768
|
+
if (target) {
|
|
769
|
+
if (params.sourceSession !== undefined) target.sourceSession = params.sourceSession;
|
|
770
|
+
if (params.derivedFrom !== undefined) target.derivedFrom = params.derivedFrom;
|
|
771
|
+
if (params.informs !== undefined) target.informs = params.informs;
|
|
772
|
+
}
|
|
773
|
+
saveScores(cwd, scores);
|
|
774
|
+
|
|
775
|
+
// Update embeddings
|
|
776
|
+
removeEmbedding(cwd, text, category);
|
|
777
|
+
await embedEntry(cwd, newText, category);
|
|
778
|
+
|
|
779
|
+
return { content: [{ type: "text", text: `Updated: [${category}] ${text}\n → [${category}] ${newText}` }] };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (op === "invalidate") {
|
|
783
|
+
// Remove the old entry
|
|
784
|
+
lines.splice(matchIndex, 1);
|
|
785
|
+
const cleaned = lines.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
786
|
+
writeFileSync(topicPath, cleaned, "utf-8");
|
|
787
|
+
|
|
788
|
+
// Clean up scores and embeddings for old entry
|
|
789
|
+
const scores = loadScores(cwd);
|
|
790
|
+
const oldHash = entryHash(`- [${category}] ${text}`);
|
|
791
|
+
if (scores.entries[oldHash]) {
|
|
792
|
+
delete scores.entries[oldHash];
|
|
793
|
+
saveScores(cwd, scores);
|
|
794
|
+
}
|
|
795
|
+
removeEmbedding(cwd, text, category);
|
|
796
|
+
|
|
797
|
+
let result = `Invalidated: [${category}] ${text}`;
|
|
798
|
+
if (supersededBy) {
|
|
799
|
+
result += `\nSuperseded by: ${supersededBy}`;
|
|
800
|
+
}
|
|
801
|
+
return { content: [{ type: "text", text: result }] };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return { content: [{ type: "text", text: `Unknown operation: ${op}` }] };
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ── memory_consolidate ──────────────────────────────────────────────
|
|
810
|
+
|
|
811
|
+
function registerMemoryConsolidate(pi: ExtensionAPI): void {
|
|
812
|
+
pi.registerTool({
|
|
813
|
+
name: "memory_consolidate",
|
|
814
|
+
label: "Memory Consolidate",
|
|
815
|
+
description:
|
|
816
|
+
"Analyze all memories and produce a consolidated summary. " +
|
|
817
|
+
"Identifies patterns, removes contradictions, merges related entries, " +
|
|
818
|
+
"and generates reusable skills from recurring multi-step workflows. " +
|
|
819
|
+
"Run periodically or when memory is getting large.",
|
|
820
|
+
parameters: Type.Object({}),
|
|
821
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
822
|
+
const cwd = ctx.cwd;
|
|
823
|
+
const topicEntries = readAllTopicEntries(cwd);
|
|
824
|
+
if (topicEntries.length === 0) {
|
|
825
|
+
return { content: [{ type: "text", text: "No memories to consolidate." }] };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Group entries by category
|
|
829
|
+
const byCategory: Record<string, string[]> = {};
|
|
830
|
+
for (const entry of topicEntries) {
|
|
831
|
+
if (!byCategory[entry.category]) byCategory[entry.category] = [];
|
|
832
|
+
byCategory[entry.category].push(`${entry.pinned ? "(pinned) " : ""}${entry.text}`);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Build consolidation prompt for the AI
|
|
836
|
+
let report = `## Memory Consolidation Report\n\n`;
|
|
837
|
+
report += `**Total entries:** ${topicEntries.length}\n`;
|
|
838
|
+
report += `**Categories:** ${Object.keys(byCategory).join(", ")}\n\n`;
|
|
839
|
+
|
|
840
|
+
for (const [cat, entries] of Object.entries(byCategory)) {
|
|
841
|
+
report += `### ${cat} (${entries.length})\n`;
|
|
842
|
+
for (const e of entries) {
|
|
843
|
+
report += `- ${e}\n`;
|
|
844
|
+
}
|
|
845
|
+
report += "\n";
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
report += `## Instructions\n\n`;
|
|
849
|
+
report += `Review the above memories and take ALL of these actions:\n`;
|
|
850
|
+
report += `1. **Identify contradictions** — if two entries conflict, keep the newer/more specific one. Use memory_edit to invalidate the stale one.\n`;
|
|
851
|
+
report += `2. **Merge duplicates** — if entries say the same thing differently, keep the best version. Use memory_edit to update.\n`;
|
|
852
|
+
report += `3. **Promotion destinations** — append candidates to \`.pi/memory/promotions.md\` with destination:\n`;
|
|
853
|
+
report += ` \`memory\` | \`skill\` | \`enforcement\` | \`project_rule\` | \`discard\`.\n`;
|
|
854
|
+
report += ` - skill: suggest /create-skill or note existing .pi/skills/; set skill_created if you write one\n`;
|
|
855
|
+
report += ` - enforcement: propose trigger/action when mechanical; do not invent run_after\n`;
|
|
856
|
+
report += ` - project_rule: propose only — NEVER write rules/ or .pi/rules/\n`;
|
|
857
|
+
report += ` Block format: ### <id> then fields destination, status: proposed, category, text, reason, created\n`;
|
|
858
|
+
report += `4. **Remove stale** — if entries reference things that no longer exist or apply, use memory_remove.\n`;
|
|
859
|
+
report += `5. **NEVER touch pinned entries** — they are user-explicit and permanent.\n\n`;
|
|
860
|
+
report += `*Take actions using memory_edit and memory_remove. Write promotions.md for graduation candidates.*`;
|
|
861
|
+
|
|
862
|
+
return { content: [{ type: "text", text: report }] };
|
|
863
|
+
},
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ── registerMemoryTools (entry point) ───────────────────────────────
|
|
868
|
+
|
|
869
|
+
export function registerMemoryTools(pi: ExtensionAPI): void {
|
|
870
|
+
// Only register in the orchestrator, not subagents
|
|
871
|
+
if (process.env.PI_SUBAGENT_CHILD === "1") return;
|
|
872
|
+
|
|
873
|
+
const state = { embeddingsMigrated: false };
|
|
874
|
+
|
|
875
|
+
registerMemorySearch(pi, state);
|
|
876
|
+
registerMemoryReinforce(pi);
|
|
877
|
+
registerMemoryTopics(pi);
|
|
878
|
+
registerMemoryAdd(pi);
|
|
879
|
+
registerMemoryRemove(pi);
|
|
880
|
+
registerMemoryReflect(pi);
|
|
881
|
+
registerMemoryEdit(pi);
|
|
882
|
+
registerMemoryConsolidate(pi);
|
|
883
|
+
}
|