omegon 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.
- package/.gitattributes +3 -0
- package/AGENTS.md +16 -0
- package/LICENSE +15 -0
- package/README.md +289 -0
- package/bin/pi.mjs +30 -0
- package/extensions/00-secrets/index.ts +1126 -0
- package/extensions/01-auth/auth.ts +401 -0
- package/extensions/01-auth/index.ts +289 -0
- package/extensions/auto-compact.ts +42 -0
- package/extensions/bootstrap/deps.ts +291 -0
- package/extensions/bootstrap/index.ts +811 -0
- package/extensions/chronos/chronos.sh +487 -0
- package/extensions/chronos/index.ts +148 -0
- package/extensions/cleave/assessment.ts +754 -0
- package/extensions/cleave/bridge.ts +31 -0
- package/extensions/cleave/conflicts.ts +250 -0
- package/extensions/cleave/dispatcher.ts +808 -0
- package/extensions/cleave/guardrails.ts +426 -0
- package/extensions/cleave/index.ts +3121 -0
- package/extensions/cleave/lifecycle-emitter.ts +20 -0
- package/extensions/cleave/openspec.ts +811 -0
- package/extensions/cleave/planner.ts +260 -0
- package/extensions/cleave/review.ts +579 -0
- package/extensions/cleave/skills.ts +355 -0
- package/extensions/cleave/types.ts +261 -0
- package/extensions/cleave/workspace.ts +861 -0
- package/extensions/cleave/worktree.ts +243 -0
- package/extensions/core-renderers.ts +253 -0
- package/extensions/dashboard/context-gauge.ts +58 -0
- package/extensions/dashboard/file-watch.ts +14 -0
- package/extensions/dashboard/footer.ts +1145 -0
- package/extensions/dashboard/git.ts +185 -0
- package/extensions/dashboard/index.ts +478 -0
- package/extensions/dashboard/memory-audit.ts +34 -0
- package/extensions/dashboard/overlay-data.ts +705 -0
- package/extensions/dashboard/overlay.ts +365 -0
- package/extensions/dashboard/render-utils.ts +54 -0
- package/extensions/dashboard/types.ts +191 -0
- package/extensions/dashboard/uri-helper.ts +45 -0
- package/extensions/debug.ts +69 -0
- package/extensions/defaults.ts +282 -0
- package/extensions/design-tree/dashboard-state.ts +161 -0
- package/extensions/design-tree/design-card.ts +362 -0
- package/extensions/design-tree/index.ts +2130 -0
- package/extensions/design-tree/lifecycle-emitter.ts +41 -0
- package/extensions/design-tree/tree.ts +1607 -0
- package/extensions/design-tree/types.ts +163 -0
- package/extensions/distill.ts +127 -0
- package/extensions/effort/index.ts +395 -0
- package/extensions/effort/tiers.ts +146 -0
- package/extensions/effort/types.ts +105 -0
- package/extensions/lib/git-state.ts +227 -0
- package/extensions/lib/local-models.ts +157 -0
- package/extensions/lib/model-preferences.ts +51 -0
- package/extensions/lib/model-routing.ts +720 -0
- package/extensions/lib/operator-fallback.ts +205 -0
- package/extensions/lib/operator-profile.ts +360 -0
- package/extensions/lib/slash-command-bridge.ts +253 -0
- package/extensions/lib/typebox-helpers.ts +16 -0
- package/extensions/local-inference/index.ts +727 -0
- package/extensions/mcp-bridge/README.md +220 -0
- package/extensions/mcp-bridge/index.ts +951 -0
- package/extensions/mcp-bridge/lib.ts +365 -0
- package/extensions/mcp-bridge/mcp.json +3 -0
- package/extensions/mcp-bridge/package.json +11 -0
- package/extensions/model-budget.ts +752 -0
- package/extensions/offline-driver.ts +403 -0
- package/extensions/openspec/archive-gate.ts +164 -0
- package/extensions/openspec/branch-cleanup.ts +64 -0
- package/extensions/openspec/dashboard-state.ts +50 -0
- package/extensions/openspec/index.ts +1917 -0
- package/extensions/openspec/lifecycle-emitter.ts +65 -0
- package/extensions/openspec/lifecycle-files.ts +70 -0
- package/extensions/openspec/lifecycle.ts +50 -0
- package/extensions/openspec/reconcile.ts +187 -0
- package/extensions/openspec/spec.ts +1385 -0
- package/extensions/openspec/types.ts +98 -0
- package/extensions/project-memory/DESIGN-global-mind.md +198 -0
- package/extensions/project-memory/README.md +202 -0
- package/extensions/project-memory/api-types.ts +382 -0
- package/extensions/project-memory/compaction-policy.ts +29 -0
- package/extensions/project-memory/core.ts +164 -0
- package/extensions/project-memory/embeddings.ts +230 -0
- package/extensions/project-memory/extraction-v2.ts +861 -0
- package/extensions/project-memory/factstore.ts +2177 -0
- package/extensions/project-memory/index.ts +3459 -0
- package/extensions/project-memory/injection-metrics.ts +91 -0
- package/extensions/project-memory/jsonl-io.ts +12 -0
- package/extensions/project-memory/lifecycle.ts +331 -0
- package/extensions/project-memory/migration.ts +293 -0
- package/extensions/project-memory/package.json +9 -0
- package/extensions/project-memory/sci-renderers.ts +7 -0
- package/extensions/project-memory/template.ts +103 -0
- package/extensions/project-memory/triggers.ts +52 -0
- package/extensions/project-memory/types.ts +102 -0
- package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
- package/extensions/render/composition/package-lock.json +534 -0
- package/extensions/render/composition/package.json +22 -0
- package/extensions/render/composition/render.mjs +246 -0
- package/extensions/render/composition/test-comp.tsx +87 -0
- package/extensions/render/composition/types.ts +24 -0
- package/extensions/render/excalidraw/UPSTREAM.md +81 -0
- package/extensions/render/excalidraw/elements.ts +764 -0
- package/extensions/render/excalidraw/index.ts +66 -0
- package/extensions/render/excalidraw/types.ts +223 -0
- package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
- package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
- package/extensions/render/excalidraw-renderer/render_template.html +59 -0
- package/extensions/render/index.ts +830 -0
- package/extensions/render/native-diagrams/index.ts +57 -0
- package/extensions/render/native-diagrams/motifs.ts +542 -0
- package/extensions/render/native-diagrams/raster.ts +8 -0
- package/extensions/render/native-diagrams/scene.ts +75 -0
- package/extensions/render/native-diagrams/spec.ts +204 -0
- package/extensions/render/native-diagrams/svg.ts +116 -0
- package/extensions/sci-ui.ts +304 -0
- package/extensions/session-log.ts +174 -0
- package/extensions/shared-state.ts +146 -0
- package/extensions/spinner-verbs.ts +91 -0
- package/extensions/style.ts +281 -0
- package/extensions/terminal-title.ts +191 -0
- package/extensions/tool-profile/index.ts +291 -0
- package/extensions/tool-profile/profiles.ts +290 -0
- package/extensions/types.d.ts +9 -0
- package/extensions/vault/index.ts +185 -0
- package/extensions/version-check.ts +90 -0
- package/extensions/view/index.ts +859 -0
- package/extensions/view/uri-resolver.ts +148 -0
- package/extensions/web-search/index.ts +182 -0
- package/extensions/web-search/providers.ts +121 -0
- package/extensions/web-ui/index.ts +110 -0
- package/extensions/web-ui/server.ts +265 -0
- package/extensions/web-ui/state.ts +462 -0
- package/extensions/web-ui/static/index.html +145 -0
- package/extensions/web-ui/types.ts +284 -0
- package/package.json +76 -0
- package/prompts/init.md +75 -0
- package/prompts/new-repo.md +54 -0
- package/prompts/oci-login.md +56 -0
- package/prompts/status.md +50 -0
- package/settings.json +4 -0
- package/skills/cleave/SKILL.md +218 -0
- package/skills/git/SKILL.md +209 -0
- package/skills/git/_reference/ci-validation.md +204 -0
- package/skills/oci/SKILL.md +338 -0
- package/skills/openspec/SKILL.md +346 -0
- package/skills/pi-extensions/SKILL.md +191 -0
- package/skills/pi-tui/SKILL.md +517 -0
- package/skills/python/SKILL.md +189 -0
- package/skills/rust/SKILL.md +268 -0
- package/skills/security/SKILL.md +206 -0
- package/skills/style/SKILL.md +264 -0
- package/skills/typescript/SKILL.md +225 -0
- package/skills/vault/SKILL.md +102 -0
- package/themes/alpharius-legacy.json +85 -0
- package/themes/alpharius.conf +59 -0
- package/themes/alpharius.json +88 -0
|
@@ -0,0 +1,3459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Memory Extension
|
|
3
|
+
*
|
|
4
|
+
* Persistent, cross-session project knowledge stored in SQLite with
|
|
5
|
+
* confidence-decay reinforcement, semantic retrieval via cloud-first embeddings,
|
|
6
|
+
* episodic session narratives, and working memory.
|
|
7
|
+
*
|
|
8
|
+
* Storage: .pi/memory/facts.db (SQLite with WAL mode)
|
|
9
|
+
* Vectors: facts_vec / episodes_vec tables (Float32 BLOBs via configured embeddings)
|
|
10
|
+
* Rendering: Active facts → Markdown-KV for LLM context injection
|
|
11
|
+
*
|
|
12
|
+
* Tools:
|
|
13
|
+
* memory_query — Read all active memory (full dump, rendered Markdown-KV)
|
|
14
|
+
* memory_recall — Semantic search over active facts (targeted retrieval)
|
|
15
|
+
* memory_store — Add a fact (with conflict detection)
|
|
16
|
+
* memory_supersede — Replace a fact atomically
|
|
17
|
+
* memory_archive — Archive stale/redundant facts by ID
|
|
18
|
+
* memory_search_archive — FTS keyword search over archived facts
|
|
19
|
+
* memory_connect — Create relationships between facts
|
|
20
|
+
* memory_compact — Trigger context compaction + memory reload
|
|
21
|
+
* memory_episodes — Search session narratives (episodic memory)
|
|
22
|
+
* memory_focus — Pin facts to working memory
|
|
23
|
+
* memory_release — Clear working memory
|
|
24
|
+
*
|
|
25
|
+
* Cognitive features:
|
|
26
|
+
* - Semantic retrieval via cloud-first embeddings (default: OpenAI text-embedding-3-small)
|
|
27
|
+
* - Contextual auto-injection (relevant facts only, not full dump)
|
|
28
|
+
* - Working memory buffer (pinned facts survive compaction)
|
|
29
|
+
* - Conflict detection at store time (flags similar but not identical facts)
|
|
30
|
+
* - Episodic memory (session narratives generated at shutdown)
|
|
31
|
+
* - Background vector indexing (embeds facts async on session start)
|
|
32
|
+
*
|
|
33
|
+
* Commands:
|
|
34
|
+
* /memory — Interactive mind manager
|
|
35
|
+
* /memory edit — Edit current mind in editor
|
|
36
|
+
* /memory refresh — Re-evaluate and prune memory
|
|
37
|
+
* /memory clear — Reset current mind
|
|
38
|
+
* /memory stats — Show memory statistics
|
|
39
|
+
*
|
|
40
|
+
* Background extraction via subagent outputs JSONL actions.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import * as path from "node:path";
|
|
44
|
+
import * as os from "node:os";
|
|
45
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, SessionMessageEntry } from "@cwilson613/pi-coding-agent";
|
|
46
|
+
import { DynamicBorder } from "@cwilson613/pi-coding-agent";
|
|
47
|
+
import { sciCall, sciOk, sciErr, sciExpanded, sciLoading } from "./sci-renderers.ts";
|
|
48
|
+
import { StringEnum } from "../lib/typebox-helpers";
|
|
49
|
+
import { Type } from "@sinclair/typebox";
|
|
50
|
+
import { Container, type SelectItem, SelectList, Text } from "@cwilson613/pi-tui";
|
|
51
|
+
import { FactStore, parseExtractionOutput, GLOBAL_DECAY, type MindRecord, type Fact } from "./factstore.ts";
|
|
52
|
+
import { embed, isEmbeddingAvailable, resolveEmbeddingProvider, MODEL_DIMS, type EmbeddingProvider } from "./embeddings.ts";
|
|
53
|
+
import { DEFAULT_CONFIG, type MemoryConfig, type LifecycleMemoryCandidate } from "./types.ts";
|
|
54
|
+
import { sanitizeCompactionText, shouldInterceptCompaction } from "./compaction-policy.ts";
|
|
55
|
+
import { writeJsonlIfChanged } from "./jsonl-io.ts";
|
|
56
|
+
import {
|
|
57
|
+
createMemoryInjectionMetrics,
|
|
58
|
+
estimateTokensFromChars,
|
|
59
|
+
formatMemoryInjectionMetrics,
|
|
60
|
+
type MemoryInjectionMode,
|
|
61
|
+
type MemoryInjectionMetrics,
|
|
62
|
+
} from "./injection-metrics.ts";
|
|
63
|
+
import {
|
|
64
|
+
type ExtractionTriggerState,
|
|
65
|
+
createTriggerState,
|
|
66
|
+
shouldExtract,
|
|
67
|
+
} from "./triggers.ts";
|
|
68
|
+
import { runExtractionV2, runGlobalExtraction, killActiveExtraction, killAllSubprocesses, generateEpisode, generateEpisodeDirect, generateEpisodeWithFallback, buildTemplateEpisode, runSectionPruningPass, type SessionTelemetry } from "./extraction-v2.ts";
|
|
69
|
+
import { migrateToFactStore, needsMigration, markMigrated } from "./migration.ts";
|
|
70
|
+
import { SECTIONS } from "./template.ts";
|
|
71
|
+
import { serializeConversation, convertToLlm } from "@cwilson613/pi-coding-agent";
|
|
72
|
+
import { sharedState } from "../shared-state.ts";
|
|
73
|
+
import {
|
|
74
|
+
ingestLifecycleCandidate,
|
|
75
|
+
ingestLifecycleCandidatesBatch,
|
|
76
|
+
type LifecycleCandidate,
|
|
77
|
+
type LifecycleCandidateResult,
|
|
78
|
+
type BatchIngestResult,
|
|
79
|
+
} from "./lifecycle.ts";
|
|
80
|
+
import {
|
|
81
|
+
resolveTier,
|
|
82
|
+
getTierDisplayLabel,
|
|
83
|
+
getDefaultPolicy,
|
|
84
|
+
type ModelTier,
|
|
85
|
+
type RegistryModel
|
|
86
|
+
} from "../lib/model-routing.ts";
|
|
87
|
+
|
|
88
|
+
/** Map abstract effort model tiers to concrete cloud model IDs for extraction. */
|
|
89
|
+
const EFFORT_EXTRACTION_MODELS: Record<string, string> = {
|
|
90
|
+
gloriana: "claude-opus-4-6",
|
|
91
|
+
victory: "claude-sonnet-4-6",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Compaction prompt constants (mirrors pi's internal prompts for local-model fallback)
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
const COMPACTION_SYSTEM_PROMPT = "You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.\n\nDo NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.";
|
|
99
|
+
|
|
100
|
+
const COMPACTION_INITIAL_PROMPT = `Create a structured context checkpoint summary that another LLM will use to continue the work.
|
|
101
|
+
|
|
102
|
+
Use this EXACT format:
|
|
103
|
+
|
|
104
|
+
## Goal
|
|
105
|
+
[What is the user trying to accomplish?]
|
|
106
|
+
|
|
107
|
+
## Constraints & Preferences
|
|
108
|
+
- [Any constraints, preferences, or requirements mentioned]
|
|
109
|
+
|
|
110
|
+
## Progress
|
|
111
|
+
### Done
|
|
112
|
+
- [x] [Completed tasks/changes]
|
|
113
|
+
|
|
114
|
+
### In Progress
|
|
115
|
+
- [ ] [Current work]
|
|
116
|
+
|
|
117
|
+
### Blocked
|
|
118
|
+
- [Issues preventing progress, if any]
|
|
119
|
+
|
|
120
|
+
## Key Decisions
|
|
121
|
+
- **[Decision]**: [Brief rationale]
|
|
122
|
+
|
|
123
|
+
## Next Steps
|
|
124
|
+
1. [Ordered list of what should happen next]
|
|
125
|
+
|
|
126
|
+
## Critical Context
|
|
127
|
+
- [Any data, examples, or references needed to continue]
|
|
128
|
+
|
|
129
|
+
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
|
130
|
+
|
|
131
|
+
const COMPACTION_UPDATE_PROMPT = `Update the existing structured summary with new information from the conversation. RULES:
|
|
132
|
+
- PRESERVE all existing information from the previous summary
|
|
133
|
+
- ADD new progress, decisions, and context
|
|
134
|
+
- UPDATE Progress: move items from "In Progress" to "Done" when completed
|
|
135
|
+
- UPDATE "Next Steps" based on what was accomplished
|
|
136
|
+
|
|
137
|
+
Use the same format (Goal, Constraints & Preferences, Progress, Key Decisions, Next Steps, Critical Context).
|
|
138
|
+
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
|
139
|
+
|
|
140
|
+
const COMPACTION_TURN_PREFIX_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
|
|
141
|
+
|
|
142
|
+
Summarize the prefix to provide context for the retained suffix:
|
|
143
|
+
|
|
144
|
+
## Original Request
|
|
145
|
+
[What did the user ask for?]
|
|
146
|
+
|
|
147
|
+
## Early Progress
|
|
148
|
+
- [Key decisions and work done in the prefix]
|
|
149
|
+
|
|
150
|
+
## Context for Suffix
|
|
151
|
+
- [Information needed to understand the retained recent work]
|
|
152
|
+
|
|
153
|
+
Be concise. Focus on what's needed to understand the kept suffix.`;
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Ollama helpers for local-model compaction fallback
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
const OLLAMA_URL = () => process.env.OLLAMA_HOST || process.env.LOCAL_INFERENCE_URL || "http://localhost:11434";
|
|
160
|
+
|
|
161
|
+
/** Embedding model names that must not be used for chat completions */
|
|
162
|
+
const EMBEDDING_MODEL_PATTERN = /embed|embedding/i;
|
|
163
|
+
|
|
164
|
+
/** Preferred models for summarization, in priority order */
|
|
165
|
+
// Canonical preference list + family prefix catch-alls from shared registry.
|
|
166
|
+
// Specific tags first (largest/best wins via startsWith); families catch any
|
|
167
|
+
// installed variant not explicitly listed (e.g. qwen3:14b-q4_k_m).
|
|
168
|
+
// Edit extensions/lib/local-models.ts to update model preferences.
|
|
169
|
+
import {
|
|
170
|
+
PREFERRED_ORDER as LOCAL_MODELS_ORDER,
|
|
171
|
+
PREFERRED_FAMILIES,
|
|
172
|
+
} from "../lib/local-models.ts";
|
|
173
|
+
const PREFERRED_CHAT_MODELS = [...LOCAL_MODELS_ORDER, ...PREFERRED_FAMILIES];
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Discover a chat-capable local model via Ollama's OpenAI-compatible API.
|
|
177
|
+
* Returns model ID or null if unavailable.
|
|
178
|
+
*/
|
|
179
|
+
async function discoverLocalChatModel(): Promise<string | null> {
|
|
180
|
+
try {
|
|
181
|
+
const resp = await fetch(`${OLLAMA_URL()}/v1/models`, { signal: AbortSignal.timeout(2_000) });
|
|
182
|
+
if (!resp.ok) return null;
|
|
183
|
+
const data = await resp.json() as { data?: Array<{ id: string }> };
|
|
184
|
+
const available = (data.data?.map((m: { id: string }) => m.id) ?? [])
|
|
185
|
+
.filter((id: string) => !EMBEDDING_MODEL_PATTERN.test(id));
|
|
186
|
+
if (available.length === 0) return null;
|
|
187
|
+
|
|
188
|
+
// Try preferred models first (startsWith for exact matching)
|
|
189
|
+
for (const pref of PREFERRED_CHAT_MODELS) {
|
|
190
|
+
const found = available.find((id: string) => id.startsWith(pref));
|
|
191
|
+
if (found) return found;
|
|
192
|
+
}
|
|
193
|
+
return available[0]; // Any non-embedding model
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Send a chat completion to Ollama. Returns trimmed content or null.
|
|
201
|
+
*/
|
|
202
|
+
async function ollamaChat(
|
|
203
|
+
model: string,
|
|
204
|
+
systemPrompt: string,
|
|
205
|
+
userPrompt: string,
|
|
206
|
+
opts: { maxTokens?: number; signal?: AbortSignal },
|
|
207
|
+
): Promise<string | null> {
|
|
208
|
+
const resp = await fetch(`${OLLAMA_URL()}/v1/chat/completions`, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: { "Content-Type": "application/json" },
|
|
211
|
+
body: JSON.stringify({
|
|
212
|
+
model,
|
|
213
|
+
messages: [
|
|
214
|
+
{ role: "system", content: systemPrompt },
|
|
215
|
+
{ role: "user", content: userPrompt },
|
|
216
|
+
],
|
|
217
|
+
max_tokens: opts.maxTokens ?? 4096,
|
|
218
|
+
temperature: 0.3,
|
|
219
|
+
// Request a reasonable context window for the local model
|
|
220
|
+
num_ctx: 32768,
|
|
221
|
+
}),
|
|
222
|
+
signal: opts.signal,
|
|
223
|
+
});
|
|
224
|
+
if (!resp.ok) return null;
|
|
225
|
+
const data = await resp.json() as { choices?: Array<{ message?: { content?: string } }> };
|
|
226
|
+
const content = data.choices?.[0]?.message?.content?.trim();
|
|
227
|
+
return content || null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Format file operations for appending to compaction summary.
|
|
232
|
+
* Mirrors pi core's formatFileOperations but inlined since it's not exported.
|
|
233
|
+
*/
|
|
234
|
+
function formatFileOps(fileOps: { read: Set<string>; edited: Set<string>; written: Set<string> }): string {
|
|
235
|
+
const modified = new Set([...fileOps.edited, ...fileOps.written]);
|
|
236
|
+
const readOnly = [...fileOps.read].filter(f => !modified.has(f)).sort();
|
|
237
|
+
const modifiedFiles = [...modified].sort();
|
|
238
|
+
|
|
239
|
+
const sections: string[] = [];
|
|
240
|
+
if (readOnly.length > 0) sections.push(`<read-files>\n${readOnly.join("\n")}\n</read-files>`);
|
|
241
|
+
if (modifiedFiles.length > 0) sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
|
|
242
|
+
return sections.length > 0 ? `\n\n${sections.join("\n\n")}` : "";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Build details object for CompactionResult from file operations.
|
|
247
|
+
*/
|
|
248
|
+
function buildFileDetails(fileOps: { read: Set<string>; edited: Set<string>; written: Set<string> }) {
|
|
249
|
+
const modified = new Set([...fileOps.edited, ...fileOps.written]);
|
|
250
|
+
const readFiles = [...fileOps.read].filter(f => !modified.has(f)).sort();
|
|
251
|
+
const modifiedFiles = [...modified].sort();
|
|
252
|
+
return { readFiles, modifiedFiles };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resolve the compaction fallback chain based on effort tier and routing policy.
|
|
257
|
+
*
|
|
258
|
+
* Returns an ordered array of { tier, timeout } objects representing the fallback chain.
|
|
259
|
+
* Local models are resolved via discoverLocalChatModel(), cloud models via resolveTier().
|
|
260
|
+
*/
|
|
261
|
+
function resolveCompactionFallbackChain(
|
|
262
|
+
ctx: ExtensionContext,
|
|
263
|
+
config: MemoryConfig
|
|
264
|
+
): Array<{ tier: ModelTier; timeout: number; label: string }> {
|
|
265
|
+
if (!config.compactionFallbackChain) {
|
|
266
|
+
// Legacy behavior: only try local
|
|
267
|
+
return [{ tier: "local", timeout: config.compactionLocalTimeout, label: "Local" }];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const effort = sharedState.effort;
|
|
271
|
+
const policy = sharedState.routingPolicy ?? getDefaultPolicy();
|
|
272
|
+
|
|
273
|
+
// Effort tiers 1-5: prefer local first. Tiers 6-7: can start with cloud.
|
|
274
|
+
const startWithLocal = !effort || effort.compaction === "local";
|
|
275
|
+
|
|
276
|
+
const chain: Array<{ tier: ModelTier; timeout: number; label: string }> = [];
|
|
277
|
+
|
|
278
|
+
if (startWithLocal) {
|
|
279
|
+
chain.push({ tier: "local", timeout: config.compactionLocalTimeout, label: "Local" });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Add GPT-5.3-codex-spark (free reasoning model) as priority fallback
|
|
283
|
+
chain.push({ tier: "victory", timeout: config.compactionCodexTimeout, label: "GPT-5.3-Codex-Spark" });
|
|
284
|
+
|
|
285
|
+
// Add Haiku as budget fallback
|
|
286
|
+
chain.push({ tier: "retribution", timeout: config.compactionHaikuTimeout, label: "Haiku" });
|
|
287
|
+
|
|
288
|
+
// If we started with cloud, add local as final fallback
|
|
289
|
+
if (!startWithLocal) {
|
|
290
|
+
chain.push({ tier: "local", timeout: config.compactionLocalTimeout, label: "Local" });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return chain;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// tryCompactionWithTier will be defined after helper functions
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Compute degeneracy pressure as an exponential curve from onset to warning threshold.
|
|
300
|
+
* Returns 0 below onset, 1 at warning threshold, exponential growth between.
|
|
301
|
+
*
|
|
302
|
+
* The curve is: pressure = (e^(k*t) - 1) / (e^k - 1)
|
|
303
|
+
* where t = (pct - onset) / (warning - onset), normalized 0→1
|
|
304
|
+
* and k controls steepness (higher = more exponential, 3 gives ~20:1 ratio)
|
|
305
|
+
*/
|
|
306
|
+
function computeDegeneracyPressure(
|
|
307
|
+
pct: number,
|
|
308
|
+
onset: number,
|
|
309
|
+
warning: number,
|
|
310
|
+
k = 3,
|
|
311
|
+
): number {
|
|
312
|
+
if (pct < onset) return 0;
|
|
313
|
+
if (pct >= warning) return 1;
|
|
314
|
+
const t = (pct - onset) / (warning - onset);
|
|
315
|
+
return (Math.exp(k * t) - 1) / (Math.exp(k) - 1);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Map degeneracy pressure (0→1) to a context-appropriate guidance message.
|
|
320
|
+
* Messages escalate in urgency and specificity as pressure increases.
|
|
321
|
+
*/
|
|
322
|
+
function pressureGuidance(pressure: number, pct: number): string | null {
|
|
323
|
+
if (pressure <= 0) return null;
|
|
324
|
+
|
|
325
|
+
// Five levels of escalating guidance
|
|
326
|
+
if (pressure < 0.15) {
|
|
327
|
+
return `📊 Context: ${pct}% — Wrap up current threads before starting new large tasks.`;
|
|
328
|
+
}
|
|
329
|
+
if (pressure < 0.35) {
|
|
330
|
+
return `📊 Context: ${pct}% — Finish current work, then compact before starting anything new.`;
|
|
331
|
+
}
|
|
332
|
+
if (pressure < 0.6) {
|
|
333
|
+
return `📊 Context: ${pct}% (elevated) — Complete your current task and call **memory_compact**. Avoid starting new multi-step work.`;
|
|
334
|
+
}
|
|
335
|
+
if (pressure < 0.85) {
|
|
336
|
+
return `⚠️ Context: ${pct}% (high) — You should **memory_compact** now unless you're mid-implementation with uncommitted changes. New tasks will not fit.`;
|
|
337
|
+
}
|
|
338
|
+
return `🔴 Context: ${pct}% (critical) — Call **memory_compact** immediately. All stored facts and working memory survive compaction.`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const VALID_MIND_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
342
|
+
|
|
343
|
+
function sanitizeMindName(input: string): string | null {
|
|
344
|
+
const sanitized = input.trim().replace(/[^a-zA-Z0-9_-]/g, "-").replace(/^[^a-zA-Z0-9]+/, "");
|
|
345
|
+
if (!sanitized || !VALID_MIND_NAME.test(sanitized)) return null;
|
|
346
|
+
return sanitized;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export default function (pi: ExtensionAPI) {
|
|
350
|
+
let store: FactStore | null = null;
|
|
351
|
+
let globalStore: FactStore | null = null;
|
|
352
|
+
let triggerState: ExtractionTriggerState = createTriggerState();
|
|
353
|
+
let postCompaction = false;
|
|
354
|
+
let firstTurn = true;
|
|
355
|
+
let config: MemoryConfig = { ...DEFAULT_CONFIG };
|
|
356
|
+
let activeExtractionPromise: Promise<void> | null = null;
|
|
357
|
+
let sessionActive = false;
|
|
358
|
+
/** Set by /exit handler when episode generation is done pre-goodbye */
|
|
359
|
+
let exitEpisodeDone = false;
|
|
360
|
+
/** Pending embed promises — tracked so shutdown can await them before DB close */
|
|
361
|
+
const pendingEmbeds = new Set<Promise<unknown>>();
|
|
362
|
+
let consecutiveExtractionFailures = 0;
|
|
363
|
+
let memoryDir = "";
|
|
364
|
+
|
|
365
|
+
// --- Session Telemetry (for task-completion facts + template episode fallback) ---
|
|
366
|
+
/** Files written this session (Write tool calls that succeeded) */
|
|
367
|
+
const sessionFilesWritten: string[] = [];
|
|
368
|
+
/** Files edited this session (Edit tool calls that succeeded) */
|
|
369
|
+
const sessionFilesEdited: string[] = [];
|
|
370
|
+
/** Pending write/edit args, keyed by toolCallId, collected from tool_call events */
|
|
371
|
+
const pendingWriteEditArgs = new Map<string, { toolName: string; path: string }>();
|
|
372
|
+
/** Proactive startup payload — injected on firstTurn before semantic retrieval */
|
|
373
|
+
let startupInjectionPayload: string | null = null;
|
|
374
|
+
const globalMemoryDir = path.join(os.homedir(), ".pi", "memory");
|
|
375
|
+
|
|
376
|
+
// --- Context Pressure State ---
|
|
377
|
+
let compactionWarned = false; // true after we've injected a warning this cycle
|
|
378
|
+
let autoCompacted = false; // true after auto-compaction triggered this cycle
|
|
379
|
+
let compactionRetryCount = 0; // consecutive compaction failures this session
|
|
380
|
+
let useLocalCompaction = false; // set true after cloud failure to trigger local fallback
|
|
381
|
+
|
|
382
|
+
// --- Embedding / Semantic Retrieval State ---
|
|
383
|
+
let embeddingAvailable = false;
|
|
384
|
+
let embeddingModel: string | undefined;
|
|
385
|
+
|
|
386
|
+
// --- Working Memory Buffer (session-scoped) ---
|
|
387
|
+
/** Fact IDs the agent has explicitly recalled or stored this session */
|
|
388
|
+
const workingMemory = new Set<string>();
|
|
389
|
+
const WORKING_MEMORY_CAP = 25;
|
|
390
|
+
|
|
391
|
+
// --- Injection Calibration State ---
|
|
392
|
+
let pendingInjectionCalibration: {
|
|
393
|
+
baselineContextTokens: number | null;
|
|
394
|
+
userPromptTokensEstimate: number;
|
|
395
|
+
} | null = null;
|
|
396
|
+
|
|
397
|
+
/** Get the active mind name (null = default) */
|
|
398
|
+
/**
|
|
399
|
+
* Apply the current effort tier's extraction override to a MemoryConfig.
|
|
400
|
+
* Called at extraction call-time so mid-session /effort switches take effect
|
|
401
|
+
* immediately without requiring a session restart.
|
|
402
|
+
* Returns a new config object (does not mutate).
|
|
403
|
+
*/
|
|
404
|
+
function applyEffortToCfg(cfg: MemoryConfig): MemoryConfig {
|
|
405
|
+
const effort = sharedState.effort;
|
|
406
|
+
if (!effort) return cfg;
|
|
407
|
+
if (effort.extraction === "local") return cfg;
|
|
408
|
+
const model = EFFORT_EXTRACTION_MODELS[effort.extraction];
|
|
409
|
+
if (!model) return cfg;
|
|
410
|
+
return { ...cfg, extractionModel: model };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function activeMind(): string {
|
|
414
|
+
return store?.getActiveMind() ?? "default";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function activeLabel(): string {
|
|
418
|
+
const mind = store?.getActiveMind();
|
|
419
|
+
return mind ?? "default";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --- Embedding Helpers ---
|
|
423
|
+
|
|
424
|
+
function getEmbeddingOpts(): { provider: EmbeddingProvider; model: string } | null {
|
|
425
|
+
if (!embeddingModel) return null;
|
|
426
|
+
return {
|
|
427
|
+
provider: config.embeddingProvider,
|
|
428
|
+
model: embeddingModel,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Fire-and-forget embed with tracking. The promise is added to pendingEmbeds
|
|
434
|
+
* and auto-removed on completion. Shutdown awaits all pending before DB close.
|
|
435
|
+
*/
|
|
436
|
+
function trackEmbed(p: Promise<unknown>): void {
|
|
437
|
+
pendingEmbeds.add(p);
|
|
438
|
+
p.finally(() => pendingEmbeds.delete(p));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Embed a single text, returning the vector or null if unavailable */
|
|
442
|
+
async function embedText(text: string): Promise<Float32Array | null> {
|
|
443
|
+
if (!embeddingAvailable) return null;
|
|
444
|
+
const opts = getEmbeddingOpts();
|
|
445
|
+
if (!opts) return null;
|
|
446
|
+
const result = await embed(text, opts);
|
|
447
|
+
return result?.embedding ?? null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Embed a fact and store its vector. Returns true if successful.
|
|
452
|
+
* No-op if embeddings are unavailable or fact already has a vector.
|
|
453
|
+
*/
|
|
454
|
+
async function embedFact(factId: string): Promise<boolean> {
|
|
455
|
+
if (!embeddingAvailable || !store) return false;
|
|
456
|
+
if (store.hasFactVector(factId)) return true;
|
|
457
|
+
const fact = store.getFact(factId);
|
|
458
|
+
if (!fact || fact.status !== "active") return false;
|
|
459
|
+
const opts = getEmbeddingOpts();
|
|
460
|
+
if (!opts) return false;
|
|
461
|
+
const result = await embed(
|
|
462
|
+
`[${fact.section}] ${fact.content}`,
|
|
463
|
+
opts,
|
|
464
|
+
);
|
|
465
|
+
if (!result) return false;
|
|
466
|
+
store.storeFactVector(factId, result.embedding, result.model);
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Background index: embed all active facts missing vectors.
|
|
472
|
+
* Runs async, doesn't block session. Reports progress via status bar.
|
|
473
|
+
*/
|
|
474
|
+
async function backgroundIndexFacts(ctx: ExtensionContext): Promise<void> {
|
|
475
|
+
if (!embeddingAvailable || !store) return;
|
|
476
|
+
const mind = activeMind();
|
|
477
|
+
const totalActive = store.countActiveFacts(mind);
|
|
478
|
+
const missing = store.getFactsMissingVectors(mind);
|
|
479
|
+
|
|
480
|
+
// Health check: warn if coverage has degraded significantly
|
|
481
|
+
if (totalActive > 0) {
|
|
482
|
+
const coverage = 1 - missing.length / totalActive;
|
|
483
|
+
if (coverage < 0.5) {
|
|
484
|
+
console.error(`[project-memory] WARNING: vector coverage critically low: ${Math.round(coverage * 100)}% (${missing.length}/${totalActive} facts missing vectors)`);
|
|
485
|
+
} else if (coverage < 0.9 && missing.length > 10) {
|
|
486
|
+
console.warn(`[project-memory] vector coverage: ${Math.round(coverage * 100)}% — indexing ${missing.length} facts`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (missing.length === 0) return;
|
|
491
|
+
|
|
492
|
+
let indexed = 0;
|
|
493
|
+
let failed = 0;
|
|
494
|
+
let consecutiveFailures = 0;
|
|
495
|
+
for (const factId of missing) {
|
|
496
|
+
if (!sessionActive) break; // Stop if session is shutting down
|
|
497
|
+
const ok = await embedFact(factId);
|
|
498
|
+
if (ok) {
|
|
499
|
+
indexed++;
|
|
500
|
+
consecutiveFailures = 0;
|
|
501
|
+
} else {
|
|
502
|
+
failed++;
|
|
503
|
+
consecutiveFailures++;
|
|
504
|
+
// If 5 consecutive failures, the embedding provider is likely down.
|
|
505
|
+
// Stop early to avoid burning time on a dead service.
|
|
506
|
+
if (consecutiveFailures >= 5) {
|
|
507
|
+
console.error(`[project-memory] embedding indexer: 5 consecutive failures, stopping early (indexed ${indexed}, failed ${failed} of ${missing.length})`);
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (indexed > 0 || failed > 0) {
|
|
514
|
+
const finalVecs = store.countFactVectors(mind);
|
|
515
|
+
const finalCoverage = totalActive > 0 ? Math.round((finalVecs / totalActive) * 100) : 100;
|
|
516
|
+
if (ctx.hasUI) {
|
|
517
|
+
if (failed > 0 && consecutiveFailures >= 5) {
|
|
518
|
+
ctx.ui.notify(
|
|
519
|
+
`Embedding indexer stopped: ${indexed} indexed, ${failed} failed (provider may be down). Coverage: ${finalCoverage}%`,
|
|
520
|
+
"warning",
|
|
521
|
+
);
|
|
522
|
+
} else if (indexed > 5) {
|
|
523
|
+
// Only notify if we indexed a meaningful batch — don't spam for 1-2 new facts
|
|
524
|
+
ctx.ui.notify(
|
|
525
|
+
`Indexed ${indexed} facts for semantic search (${finalCoverage}% coverage)`,
|
|
526
|
+
"info",
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (failed > 0) {
|
|
531
|
+
console.warn(`[project-memory] background indexing: ${indexed} indexed, ${failed} failed, coverage ${finalCoverage}%`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Also index global store facts
|
|
536
|
+
if (globalStore) {
|
|
537
|
+
const globalMind = globalStore.getActiveMind() ?? "default";
|
|
538
|
+
const globalMissing = globalStore.getFactsMissingVectors(globalMind);
|
|
539
|
+
for (const factId of globalMissing) {
|
|
540
|
+
if (!sessionActive) break;
|
|
541
|
+
const fact = globalStore.getFact(factId);
|
|
542
|
+
if (!fact || fact.status !== "active") continue;
|
|
543
|
+
const opts = getEmbeddingOpts();
|
|
544
|
+
if (!opts) continue;
|
|
545
|
+
const result = await embed(
|
|
546
|
+
`[${fact.section}] ${fact.content}`,
|
|
547
|
+
opts,
|
|
548
|
+
);
|
|
549
|
+
if (result) {
|
|
550
|
+
globalStore.storeFactVector(factId, result.embedding, result.model);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Add fact IDs to working memory, evicting oldest if over cap */
|
|
557
|
+
function addToWorkingMemory(...ids: string[]): void {
|
|
558
|
+
for (const id of ids) {
|
|
559
|
+
// If already present, remove and re-add to refresh position
|
|
560
|
+
workingMemory.delete(id);
|
|
561
|
+
workingMemory.add(id);
|
|
562
|
+
}
|
|
563
|
+
// Evict oldest if over cap
|
|
564
|
+
while (workingMemory.size > WORKING_MEMORY_CAP) {
|
|
565
|
+
const oldest = workingMemory.values().next().value;
|
|
566
|
+
if (oldest) workingMemory.delete(oldest);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// --- Lifecycle ---
|
|
571
|
+
|
|
572
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
573
|
+
drainLifecycleCandidateQueue(ctx);
|
|
574
|
+
drainFactArchiveQueue();
|
|
575
|
+
memoryDir = path.join(ctx.cwd, ".pi", "memory");
|
|
576
|
+
|
|
577
|
+
// Initialize project store
|
|
578
|
+
try {
|
|
579
|
+
if (needsMigration(memoryDir)) {
|
|
580
|
+
store = new FactStore(memoryDir);
|
|
581
|
+
const result = migrateToFactStore(memoryDir, store);
|
|
582
|
+
markMigrated(memoryDir);
|
|
583
|
+
if (ctx.hasUI) {
|
|
584
|
+
const msg = `Memory migrated to SQLite: ${result.factsImported} facts imported, ${result.archiveFactsImported} archive facts, ${result.mindsImported} minds`;
|
|
585
|
+
ctx.ui.notify(msg, "info");
|
|
586
|
+
}
|
|
587
|
+
} else {
|
|
588
|
+
store = new FactStore(memoryDir);
|
|
589
|
+
}
|
|
590
|
+
} catch (err: any) {
|
|
591
|
+
const hint = /DLOPEN|NODE_MODULE_VERSION|compiled against/.test(err.message)
|
|
592
|
+
? "\nFix: run `npm rebuild better-sqlite3` in the Omegon directory, then restart."
|
|
593
|
+
: "";
|
|
594
|
+
ctx.ui.notify(
|
|
595
|
+
`[project-memory] Failed to open project database: ${err.message}${hint}`,
|
|
596
|
+
"error"
|
|
597
|
+
);
|
|
598
|
+
// store stays null — tools will report "not initialized"
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Initialize global store (user-level, shared across projects)
|
|
602
|
+
// Uses global.db to avoid collision with project facts.db when CWD is ~/
|
|
603
|
+
try {
|
|
604
|
+
globalStore = new FactStore(globalMemoryDir, { decay: GLOBAL_DECAY, dbName: "global.db" });
|
|
605
|
+
} catch (err: any) {
|
|
606
|
+
const hint = /DLOPEN|NODE_MODULE_VERSION|compiled against/.test(err.message)
|
|
607
|
+
? "\nFix: run `npm rebuild better-sqlite3` in the Omegon directory, then restart."
|
|
608
|
+
: "";
|
|
609
|
+
ctx.ui.notify(
|
|
610
|
+
`[project-memory] Failed to open global database: ${err.message}${hint}`,
|
|
611
|
+
"error"
|
|
612
|
+
);
|
|
613
|
+
// globalStore stays null — global features degrade gracefully
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Auto-import: always merge facts.jsonl into DB on startup.
|
|
617
|
+
// importFromJsonl deduplicates by content_hash — existing facts get reinforced,
|
|
618
|
+
// new facts get inserted. This is safe to run every session because it's additive.
|
|
619
|
+
//
|
|
620
|
+
// Previous mtime-based gating was broken: new FactStore() creates/opens the DB
|
|
621
|
+
// (setting mtime=NOW) before this check runs, so jsonlMtime > dbMtime was always
|
|
622
|
+
// false for fresh DBs, silently skipping import and then overwriting the JSONL
|
|
623
|
+
// on shutdown with only the current session's facts.
|
|
624
|
+
const jsonlPath = path.join(memoryDir, "facts.jsonl");
|
|
625
|
+
try {
|
|
626
|
+
const fsSync = await import("node:fs");
|
|
627
|
+
if (fsSync.existsSync(jsonlPath)) {
|
|
628
|
+
const jsonl = fsSync.readFileSync(jsonlPath, "utf8");
|
|
629
|
+
if (jsonl.trim()) {
|
|
630
|
+
const result = store!.importFromJsonl(jsonl);
|
|
631
|
+
if (ctx.hasUI && (result.factsAdded > 0 || result.edgesAdded > 0)) {
|
|
632
|
+
ctx.ui.notify(
|
|
633
|
+
`Memory sync: +${result.factsAdded} facts, ${result.factsReinforced} reinforced, +${result.edgesAdded} edges`,
|
|
634
|
+
"info"
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
} catch {
|
|
640
|
+
// Best effort — don't block startup
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Ensure .gitignore covers memory/ db files but allows facts.jsonl
|
|
644
|
+
const gitignorePath = path.join(memoryDir, "..", ".gitignore");
|
|
645
|
+
try {
|
|
646
|
+
const fs = await import("node:fs");
|
|
647
|
+
let existing = fs.existsSync(gitignorePath)
|
|
648
|
+
? fs.readFileSync(gitignorePath, "utf8")
|
|
649
|
+
: "";
|
|
650
|
+
let changed = false;
|
|
651
|
+
if (!existing.includes("memory/*.db")) {
|
|
652
|
+
existing += (existing.endsWith("\n") || existing === "" ? "" : "\n") + "memory/*.db\nmemory/*.db-wal\nmemory/*.db-shm\n";
|
|
653
|
+
changed = true;
|
|
654
|
+
}
|
|
655
|
+
// Remove old blanket "memory/" ignore if present (we now want facts.jsonl tracked)
|
|
656
|
+
if (existing.includes("memory/\n")) {
|
|
657
|
+
existing = existing.replace("memory/\n", "");
|
|
658
|
+
changed = true;
|
|
659
|
+
}
|
|
660
|
+
if (changed) {
|
|
661
|
+
fs.writeFileSync(gitignorePath, existing, "utf8");
|
|
662
|
+
}
|
|
663
|
+
} catch {
|
|
664
|
+
// Best effort
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
triggerState = createTriggerState();
|
|
668
|
+
postCompaction = false;
|
|
669
|
+
firstTurn = true;
|
|
670
|
+
activeExtractionPromise = null;
|
|
671
|
+
sessionActive = true;
|
|
672
|
+
consecutiveExtractionFailures = 0;
|
|
673
|
+
compactionWarned = false;
|
|
674
|
+
autoCompacted = false;
|
|
675
|
+
workingMemory.clear();
|
|
676
|
+
sessionFilesWritten.length = 0;
|
|
677
|
+
sessionFilesEdited.length = 0;
|
|
678
|
+
pendingWriteEditArgs.clear();
|
|
679
|
+
startupInjectionPayload = null;
|
|
680
|
+
|
|
681
|
+
// Apply effort-tier overrides to extraction and compaction config.
|
|
682
|
+
// sharedState.effort is written by the effort extension's session_start,
|
|
683
|
+
// which fires before ours (effort is registered earlier in package.json).
|
|
684
|
+
config = { ...DEFAULT_CONFIG };
|
|
685
|
+
|
|
686
|
+
// Auto-detect embedding provider (Custom > Voyage > OpenAI > Ollama > FTS5 fallback)
|
|
687
|
+
// Auto-detect embedding provider from env vars or local inference.
|
|
688
|
+
// MEMORY_EMBEDDING_PROVIDER can override if user wants to force a specific provider.
|
|
689
|
+
const envEmbeddingProvider = process.env.MEMORY_EMBEDDING_PROVIDER as EmbeddingProvider | undefined;
|
|
690
|
+
if (envEmbeddingProvider === "voyage" || envEmbeddingProvider === "openai" || envEmbeddingProvider === "openai-compatible" || envEmbeddingProvider === "ollama") {
|
|
691
|
+
config.embeddingProvider = envEmbeddingProvider;
|
|
692
|
+
// When provider is explicitly set but model isn't, use provider-appropriate defaults
|
|
693
|
+
// instead of keeping DEFAULT_CONFIG's voyage model for a non-voyage provider.
|
|
694
|
+
if (!process.env.MEMORY_EMBEDDING_MODEL) {
|
|
695
|
+
const providerDefaults: Record<string, string> = {
|
|
696
|
+
voyage: "voyage-3-lite",
|
|
697
|
+
openai: "text-embedding-3-small",
|
|
698
|
+
ollama: "qwen3-embedding:0.6b",
|
|
699
|
+
"openai-compatible": "text-embedding-3-small",
|
|
700
|
+
};
|
|
701
|
+
config.embeddingModel = providerDefaults[envEmbeddingProvider] ?? config.embeddingModel;
|
|
702
|
+
}
|
|
703
|
+
} else {
|
|
704
|
+
// Auto-detect: resolveEmbeddingProvider checks env vars and falls back to Ollama.
|
|
705
|
+
// Always returns a candidate (Ollama is the unconditional fallback);
|
|
706
|
+
// isEmbeddingAvailable() validates below with a real healthcheck request.
|
|
707
|
+
const detected = resolveEmbeddingProvider();
|
|
708
|
+
if (detected) {
|
|
709
|
+
config.embeddingProvider = detected.provider;
|
|
710
|
+
config.embeddingModel = detected.model;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const envEmbeddingModel = process.env.MEMORY_EMBEDDING_MODEL?.trim();
|
|
714
|
+
if (envEmbeddingModel) config.embeddingModel = envEmbeddingModel;
|
|
715
|
+
const envExtractionModel = process.env.MEMORY_EXTRACTION_MODEL?.trim();
|
|
716
|
+
if (envExtractionModel) config.extractionModel = envExtractionModel;
|
|
717
|
+
|
|
718
|
+
const effort = sharedState.effort;
|
|
719
|
+
if (effort) {
|
|
720
|
+
// Extraction: tiers 1-5 use local (devstral default), tiers 6-7 use cloud
|
|
721
|
+
if (effort.extraction !== "local") {
|
|
722
|
+
const model = EFFORT_EXTRACTION_MODELS[effort.extraction];
|
|
723
|
+
if (model) config.extractionModel = model;
|
|
724
|
+
}
|
|
725
|
+
// Compaction: only explicitly local tiers intercept before pi core.
|
|
726
|
+
// Normal day-to-day cloud-backed tiers defer to provider-routed compaction first.
|
|
727
|
+
config.compactionLocalFirst = effort.compaction === "local";
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Detect embedding availability and start background indexing
|
|
731
|
+
try {
|
|
732
|
+
const embedStatus = await isEmbeddingAvailable({
|
|
733
|
+
provider: config.embeddingProvider,
|
|
734
|
+
model: config.embeddingModel,
|
|
735
|
+
});
|
|
736
|
+
embeddingAvailable = embedStatus.available;
|
|
737
|
+
embeddingModel = embedStatus.model;
|
|
738
|
+
if (embeddingAvailable && embeddingModel) {
|
|
739
|
+
// Purge vectors from a different model (dimension mismatch)
|
|
740
|
+
const expectedDims = embedStatus.dims ?? MODEL_DIMS[embeddingModel];
|
|
741
|
+
if (expectedDims && store) {
|
|
742
|
+
const purged = store.purgeStaleVectors(expectedDims);
|
|
743
|
+
if (purged > 0 && ctx.hasUI) {
|
|
744
|
+
ctx.ui.notify(`Purged ${purged} stale vectors (model changed to ${embeddingModel})`, "info");
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (expectedDims && globalStore) {
|
|
748
|
+
globalStore.purgeStaleVectors(expectedDims);
|
|
749
|
+
}
|
|
750
|
+
// Fire-and-forget background indexing — don't block session start
|
|
751
|
+
backgroundIndexFacts(ctx).catch((err) => {
|
|
752
|
+
console.error(`[project-memory] background indexer error:`, err?.message ?? err);
|
|
753
|
+
});
|
|
754
|
+
} else if (ctx.hasUI) {
|
|
755
|
+
// Tell the user semantic search is unavailable so they know what to expect.
|
|
756
|
+
// Common causes: Ollama not running, embedding model not pulled, no cloud API key.
|
|
757
|
+
const providerHint = config.embeddingProvider === "ollama"
|
|
758
|
+
? " (is Ollama running? try: ollama pull qwen3-embedding:0.6b)"
|
|
759
|
+
: ` (${config.embeddingProvider} — check API key)`;
|
|
760
|
+
ctx.ui.notify(
|
|
761
|
+
`Semantic search unavailable${providerHint} — using keyword search`,
|
|
762
|
+
"warning",
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
} catch {
|
|
766
|
+
embeddingAvailable = false;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// --- Decay sweep + per-section pruning (background, non-blocking) ---
|
|
770
|
+
// 1. Archive facts that have decayed below their profile's minimum confidence.
|
|
771
|
+
// Converts passive decay (lower score on read) into active archival.
|
|
772
|
+
// Without this, decayed facts accumulate forever as active but invisible.
|
|
773
|
+
// 2. When any section exceeds 60 facts, run a targeted LLM archival pass
|
|
774
|
+
// to bring it back under the ceiling. This prevents monotonic accumulation.
|
|
775
|
+
// Runs fire-and-forget so it doesn't block session startup.
|
|
776
|
+
if (store) {
|
|
777
|
+
(async () => {
|
|
778
|
+
try {
|
|
779
|
+
const mind = activeMind();
|
|
780
|
+
|
|
781
|
+
// Phase 1: Sweep decayed facts (deterministic, no LLM needed)
|
|
782
|
+
const swept = store!.sweepDecayedFacts(mind);
|
|
783
|
+
if (swept > 0 && ctx.hasUI) {
|
|
784
|
+
ctx.ui.notify(`Archived ${swept} decayed facts`, "info");
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Phase 2: Section ceiling pruning (LLM-assisted)
|
|
788
|
+
const SECTION_CEILING = 60;
|
|
789
|
+
const sectionCounts = store!.getSectionCounts(mind);
|
|
790
|
+
for (const [section, count] of sectionCounts) {
|
|
791
|
+
if (count <= SECTION_CEILING) continue;
|
|
792
|
+
// Skip Recent Work — decay sweep handles it (fast decay, no LLM needed)
|
|
793
|
+
if (section === "Recent Work") continue;
|
|
794
|
+
|
|
795
|
+
// Single query — all subsequent phases operate on this in-memory list.
|
|
796
|
+
// getFactsBySection already computes confidence via computeConfidence
|
|
797
|
+
// (canonical formula from core.ts), so sorted.confidence is correct.
|
|
798
|
+
const facts = store!.getFactsBySection(mind, section);
|
|
799
|
+
const excess = count - SECTION_CEILING;
|
|
800
|
+
let archived = 0;
|
|
801
|
+
const archivedIds = new Set<string>();
|
|
802
|
+
|
|
803
|
+
// Phase 2a: Deterministic cull — archive only facts with confidence
|
|
804
|
+
// below a hard floor. This protects semantically important facts that
|
|
805
|
+
// happen to have low confidence purely due to age. Facts above the
|
|
806
|
+
// floor are sent to the LLM for judgment (phase 2b).
|
|
807
|
+
const CONFIDENCE_FLOOR = 0.25;
|
|
808
|
+
const sorted = [...facts].sort((a, b) => a.confidence - b.confidence);
|
|
809
|
+
for (const f of sorted) {
|
|
810
|
+
if (archived >= excess) break; // enough culled
|
|
811
|
+
if (f.confidence > CONFIDENCE_FLOOR) break; // above floor → LLM decides
|
|
812
|
+
store!.archiveFact(f.id);
|
|
813
|
+
archivedIds.add(f.id);
|
|
814
|
+
archived++;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Phase 2b: LLM-assisted pruning for the remaining excess.
|
|
818
|
+
// Filter the in-memory array (no re-query) and send a manageable
|
|
819
|
+
// batch (up to 80 facts) to the LLM for nuanced review.
|
|
820
|
+
const remaining = facts.filter(f => !archivedIds.has(f.id));
|
|
821
|
+
const stillExcess = remaining.length - SECTION_CEILING;
|
|
822
|
+
if (stillExcess > 0) {
|
|
823
|
+
// Send the bottom 80 by confidence for LLM review
|
|
824
|
+
const batch = [...remaining]
|
|
825
|
+
.sort((a, b) => a.confidence - b.confidence)
|
|
826
|
+
.slice(0, Math.min(80, remaining.length));
|
|
827
|
+
const idsToArchive = await runSectionPruningPass(section, batch, SECTION_CEILING, config);
|
|
828
|
+
const validIds = new Set(batch.map(f => f.id));
|
|
829
|
+
const safeToArchive = idsToArchive.filter(id => validIds.has(id));
|
|
830
|
+
for (const id of safeToArchive) {
|
|
831
|
+
store!.archiveFact(id);
|
|
832
|
+
archived++;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (archived > 0 && ctx.hasUI) {
|
|
837
|
+
ctx.ui.notify(
|
|
838
|
+
`Memory pruned ${archived} facts from ${section} (was ${count}, ceiling ${SECTION_CEILING})`,
|
|
839
|
+
"info",
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
} catch {
|
|
844
|
+
// Best effort — don't interrupt session
|
|
845
|
+
}
|
|
846
|
+
})();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// --- Proactive startup injection (session-continuity) ---
|
|
850
|
+
// Inject three layers before the user's first message so continuation
|
|
851
|
+
// questions work without waiting for semantic retrieval.
|
|
852
|
+
// Layer 1: last 1 session episode (most recent — use memory_episodes for more)
|
|
853
|
+
// Layer 2: top-15 recently-reinforced facts (recency window, cross-section)
|
|
854
|
+
// Layer 3: Decisions + Constraints + Known Issues always; Architecture capped at 10
|
|
855
|
+
//
|
|
856
|
+
// Architecture is the largest section by fact count. Loading all Architecture
|
|
857
|
+
// facts unconditionally blows context on large projects. The top-10 by recency
|
|
858
|
+
// covers active concerns; older facts are retrievable via memory_recall.
|
|
859
|
+
//
|
|
860
|
+
// This runs asynchronously so it doesn't block the TUI from appearing.
|
|
861
|
+
// The payload is injected as a pre-prompt system message on the first turn.
|
|
862
|
+
if (store) {
|
|
863
|
+
try {
|
|
864
|
+
const mind = activeMind();
|
|
865
|
+
const recentEpisodes = store.getEpisodes(mind, 1);
|
|
866
|
+
const allFacts = store.getActiveFacts(mind);
|
|
867
|
+
|
|
868
|
+
// Recency window: top 15 by last_reinforced (any section)
|
|
869
|
+
const recentFacts = [...allFacts]
|
|
870
|
+
.sort((a, b) => new Date(b.last_reinforced).getTime() - new Date(a.last_reinforced).getTime())
|
|
871
|
+
.slice(0, 15);
|
|
872
|
+
|
|
873
|
+
// Core structural facts: Decisions + Constraints + Known Issues always loaded.
|
|
874
|
+
// Architecture capped at 10 most-recently-reinforced (largest section by volume).
|
|
875
|
+
const coreFacts = allFacts.filter(f =>
|
|
876
|
+
f.section === "Decisions" || f.section === "Constraints" || f.section === "Known Issues"
|
|
877
|
+
);
|
|
878
|
+
const archFacts = [...allFacts]
|
|
879
|
+
.filter(f => f.section === "Architecture")
|
|
880
|
+
.sort((a, b) => new Date(b.last_reinforced).getTime() - new Date(a.last_reinforced).getTime())
|
|
881
|
+
.slice(0, 10);
|
|
882
|
+
|
|
883
|
+
// Merge: recent episodes + recency window + core sections (deduplicated)
|
|
884
|
+
const startupFactIds = new Set<string>();
|
|
885
|
+
const startupFacts: typeof allFacts = [];
|
|
886
|
+
for (const f of [...coreFacts, ...archFacts, ...recentFacts]) {
|
|
887
|
+
if (!startupFactIds.has(f.id)) {
|
|
888
|
+
startupFacts.push(f);
|
|
889
|
+
startupFactIds.add(f.id);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (recentEpisodes.length > 0 || startupFacts.length > 0) {
|
|
894
|
+
const lines: string[] = ["<!-- Startup Context — recent sessions and structural memory -->", ""];
|
|
895
|
+
|
|
896
|
+
if (recentEpisodes.length > 0) {
|
|
897
|
+
lines.push("## Recent Sessions");
|
|
898
|
+
lines.push("_Episodic memory — what happened and why_");
|
|
899
|
+
lines.push("");
|
|
900
|
+
for (const ep of recentEpisodes) {
|
|
901
|
+
lines.push(`### ${ep.date}: ${ep.title}`);
|
|
902
|
+
lines.push(ep.narrative);
|
|
903
|
+
lines.push("");
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (startupFacts.length > 0) {
|
|
908
|
+
const factsBySection = new Map<string, typeof startupFacts>();
|
|
909
|
+
for (const f of startupFacts) {
|
|
910
|
+
const sec = factsBySection.get(f.section) ?? [];
|
|
911
|
+
sec.push(f);
|
|
912
|
+
factsBySection.set(f.section, sec);
|
|
913
|
+
}
|
|
914
|
+
for (const [section, facts] of factsBySection) {
|
|
915
|
+
lines.push(`## ${section}`);
|
|
916
|
+
lines.push("");
|
|
917
|
+
for (const f of facts) {
|
|
918
|
+
lines.push(`- ${f.content}`);
|
|
919
|
+
}
|
|
920
|
+
lines.push("");
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
startupInjectionPayload = lines.join("\n");
|
|
925
|
+
}
|
|
926
|
+
} catch {
|
|
927
|
+
// Best effort — don't block startup
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
updateStatus(ctx);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// ---------------------------------------------------------------------------
|
|
935
|
+
// Compaction fallback chain helpers (require store to be initialized)
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Try local compaction using the existing Ollama path.
|
|
940
|
+
*/
|
|
941
|
+
async function tryLocalCompaction(
|
|
942
|
+
localModel: string,
|
|
943
|
+
prep: any,
|
|
944
|
+
customInstructions: string | undefined,
|
|
945
|
+
signal: AbortSignal
|
|
946
|
+
): Promise<{ summary: string; details: any } | null> {
|
|
947
|
+
// Build summarization prompt (same as existing logic)
|
|
948
|
+
const llmMessages = convertToLlm(prep.messagesToSummarize);
|
|
949
|
+
let conversationText = sanitizeCompactionText(serializeConversation(llmMessages));
|
|
950
|
+
|
|
951
|
+
// Truncate to ~60k chars (~15k tokens) to fit local model context windows
|
|
952
|
+
const MAX_CONVERSATION_CHARS = 60_000;
|
|
953
|
+
if (conversationText.length > MAX_CONVERSATION_CHARS) {
|
|
954
|
+
conversationText = "...[earlier conversation truncated]...\n\n"
|
|
955
|
+
+ conversationText.slice(-MAX_CONVERSATION_CHARS);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
|
|
959
|
+
if (prep.previousSummary) {
|
|
960
|
+
promptText += `<previous-summary>\n${prep.previousSummary}\n</previous-summary>\n\n`;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Inject project memory context for richer summaries
|
|
964
|
+
if (store) {
|
|
965
|
+
const mind = activeMind();
|
|
966
|
+
const facts = store.getActiveFacts(mind);
|
|
967
|
+
if (facts.length > 0) {
|
|
968
|
+
const factLines = facts.slice(0, 30).map((f: Fact) => `- [${f.section}] ${f.content}`).join("\n");
|
|
969
|
+
promptText += `<project-memory>\n${factLines}\n</project-memory>\n\n`;
|
|
970
|
+
promptText += "The project memory above provides persistent context. Reference relevant facts in your summary.\n\n";
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const basePrompt = prep.previousSummary ? COMPACTION_UPDATE_PROMPT : COMPACTION_INITIAL_PROMPT;
|
|
975
|
+
promptText += customInstructions ? `${basePrompt}\n\nAdditional focus: ${customInstructions}` : basePrompt;
|
|
976
|
+
|
|
977
|
+
// Handle split turn prefix if needed
|
|
978
|
+
let turnPrefixSummary = "";
|
|
979
|
+
if (prep.isSplitTurn && prep.turnPrefixMessages.length > 0) {
|
|
980
|
+
const prefixMessages = convertToLlm(prep.turnPrefixMessages);
|
|
981
|
+
let prefixText = sanitizeCompactionText(serializeConversation(prefixMessages));
|
|
982
|
+
if (prefixText.length > MAX_CONVERSATION_CHARS) {
|
|
983
|
+
prefixText = "...[truncated]...\n\n" + prefixText.slice(-MAX_CONVERSATION_CHARS);
|
|
984
|
+
}
|
|
985
|
+
const prefixPrompt = `<conversation>\n${prefixText}\n</conversation>\n\n${COMPACTION_TURN_PREFIX_PROMPT}`;
|
|
986
|
+
|
|
987
|
+
try {
|
|
988
|
+
const prefixResp = await ollamaChat(localModel, COMPACTION_SYSTEM_PROMPT, prefixPrompt, {
|
|
989
|
+
maxTokens: 2048, signal,
|
|
990
|
+
});
|
|
991
|
+
if (prefixResp) turnPrefixSummary = prefixResp;
|
|
992
|
+
} catch {
|
|
993
|
+
// If turn prefix fails, continue without it
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Generate main summary
|
|
998
|
+
const summary = await ollamaChat(localModel, COMPACTION_SYSTEM_PROMPT, promptText, {
|
|
999
|
+
maxTokens: 4096, signal,
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
if (!summary) return null;
|
|
1003
|
+
|
|
1004
|
+
let fullSummary = summary;
|
|
1005
|
+
if (turnPrefixSummary) {
|
|
1006
|
+
fullSummary += `\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixSummary}`;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Append file operations
|
|
1010
|
+
fullSummary += formatFileOps(prep.fileOps);
|
|
1011
|
+
|
|
1012
|
+
return {
|
|
1013
|
+
summary: fullSummary,
|
|
1014
|
+
details: buildFileDetails(prep.fileOps),
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Try cloud compaction by falling through to pi's core compaction.
|
|
1020
|
+
* This is a placeholder - we don't duplicate pi's cloud calling logic here.
|
|
1021
|
+
*/
|
|
1022
|
+
async function tryCloudCompaction(
|
|
1023
|
+
model: any,
|
|
1024
|
+
prep: any,
|
|
1025
|
+
customInstructions: string | undefined,
|
|
1026
|
+
signal: AbortSignal,
|
|
1027
|
+
ctx: ExtensionContext
|
|
1028
|
+
): Promise<{ summary: string; details: any } | null> {
|
|
1029
|
+
// We deliberately return null here to fall through to pi's core compaction
|
|
1030
|
+
// which already has the cloud model calling infrastructure.
|
|
1031
|
+
// This allows us to leverage pi's existing robust cloud compaction
|
|
1032
|
+
// while controlling which model gets selected via the fallback chain.
|
|
1033
|
+
console.log(`[project-memory] Falling through to pi core compaction with ${model.id}`);
|
|
1034
|
+
return null;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Attempt compaction with a specific model tier from the fallback chain.
|
|
1039
|
+
* Returns the compaction result or null if this tier failed.
|
|
1040
|
+
*/
|
|
1041
|
+
async function tryCompactionWithTier(
|
|
1042
|
+
tier: ModelTier,
|
|
1043
|
+
timeout: number,
|
|
1044
|
+
label: string,
|
|
1045
|
+
prep: any,
|
|
1046
|
+
customInstructions: string | undefined,
|
|
1047
|
+
signal: AbortSignal,
|
|
1048
|
+
ctx: ExtensionContext
|
|
1049
|
+
): Promise<{ summary: string; details: any } | null> {
|
|
1050
|
+
try {
|
|
1051
|
+
const timeoutSignal = AbortSignal.timeout(timeout);
|
|
1052
|
+
const combinedSignal = AbortSignal.any([signal, timeoutSignal]);
|
|
1053
|
+
|
|
1054
|
+
if (tier === "local") {
|
|
1055
|
+
// Use local Ollama path
|
|
1056
|
+
const localModel = await discoverLocalChatModel();
|
|
1057
|
+
if (!localModel) {
|
|
1058
|
+
console.log(`[project-memory] ${label} model not available`);
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
return await tryLocalCompaction(localModel, prep, customInstructions, combinedSignal);
|
|
1063
|
+
} else {
|
|
1064
|
+
// Use cloud model via model registry
|
|
1065
|
+
const all = ctx.modelRegistry.getAll() as unknown as RegistryModel[];
|
|
1066
|
+
const policy = sharedState.routingPolicy ?? getDefaultPolicy();
|
|
1067
|
+
const resolved = resolveTier(tier, all, policy);
|
|
1068
|
+
|
|
1069
|
+
if (!resolved) {
|
|
1070
|
+
console.log(`[project-memory] No ${label} model available via provider routing`);
|
|
1071
|
+
return null;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const model = all.find((m) => m.id === resolved.modelId);
|
|
1075
|
+
if (!model) {
|
|
1076
|
+
console.log(`[project-memory] ${label} model ${resolved.modelId} not found in registry`);
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
return await tryCloudCompaction(model as any, prep, customInstructions, combinedSignal, ctx);
|
|
1081
|
+
}
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
if (signal.aborted) throw err; // Don't swallow user cancellation
|
|
1084
|
+
console.log(`[project-memory] ${label} compaction failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1090
|
+
sessionActive = false;
|
|
1091
|
+
|
|
1092
|
+
// Kill any running extraction subprocess immediately.
|
|
1093
|
+
// On /reload, the old module is discarded — orphaned subprocesses with dangling
|
|
1094
|
+
// pipe listeners corrupt terminal state (ANSI escape sequences leak to stdout).
|
|
1095
|
+
// killAllSubprocesses covers both extraction and episode generation processes.
|
|
1096
|
+
killAllSubprocesses();
|
|
1097
|
+
|
|
1098
|
+
// Wait for the extraction promise to fully settle after kill.
|
|
1099
|
+
// Must not close DB until the promise resolves/rejects — otherwise the
|
|
1100
|
+
// close event handler or processExtraction() hits a closed DB.
|
|
1101
|
+
if (activeExtractionPromise) {
|
|
1102
|
+
if (ctx.hasUI) {
|
|
1103
|
+
ctx.ui.setStatus("memory", ctx.ui.theme.fg("dim", "saving memory…"));
|
|
1104
|
+
}
|
|
1105
|
+
try { await activeExtractionPromise; } catch { /* expected after kill */ }
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Episode generation: skip if /exit already did it (fast path).
|
|
1109
|
+
// For non-/exit shutdowns (ctrl-c, /reload), use the fallback chain so we
|
|
1110
|
+
// always emit at least a template episode. Guaranteed, never null.
|
|
1111
|
+
if (!exitEpisodeDone && store) {
|
|
1112
|
+
try {
|
|
1113
|
+
const mind = activeMind();
|
|
1114
|
+
const branch = ctx.sessionManager.getBranch();
|
|
1115
|
+
const messages = branch
|
|
1116
|
+
.filter((e): e is SessionMessageEntry => e.type === "message")
|
|
1117
|
+
.map((e) => e.message);
|
|
1118
|
+
|
|
1119
|
+
if (messages.length > 5) {
|
|
1120
|
+
const recentMessages = messages.slice(-20);
|
|
1121
|
+
const serialized = serializeConversation(convertToLlm(recentMessages));
|
|
1122
|
+
const sessionFactIds = [...workingMemory];
|
|
1123
|
+
const today = new Date().toISOString().split("T")[0];
|
|
1124
|
+
|
|
1125
|
+
const telemetry: SessionTelemetry = {
|
|
1126
|
+
date: today,
|
|
1127
|
+
toolCallCount: triggerState.toolCallsSinceExtract,
|
|
1128
|
+
filesWritten: [...sessionFilesWritten],
|
|
1129
|
+
filesEdited: [...sessionFilesEdited],
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
// Use fallback chain — always returns an episode (template at worst)
|
|
1133
|
+
const episodeOutput = await generateEpisodeWithFallback(serialized, telemetry, config, ctx.cwd);
|
|
1134
|
+
const episodeId = store.storeEpisode({
|
|
1135
|
+
mind,
|
|
1136
|
+
title: episodeOutput.title,
|
|
1137
|
+
narrative: episodeOutput.narrative,
|
|
1138
|
+
date: today,
|
|
1139
|
+
factIds: sessionFactIds.filter(id => store!.getFact(id)?.status === "active"),
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
if (embeddingAvailable) {
|
|
1143
|
+
const vec = await embedText(`${episodeOutput.title} ${episodeOutput.narrative}`);
|
|
1144
|
+
if (vec) store.storeEpisodeVector(episodeId, vec, embeddingModel!);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
} catch {
|
|
1148
|
+
// Best effort — don't block shutdown
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Drain pending embed promises before JSONL export + DB close.
|
|
1153
|
+
// These are fire-and-forget embedFact/embedText calls from extraction,
|
|
1154
|
+
// memory_store, and /exit episode generation. Timeout after 5s to avoid
|
|
1155
|
+
// blocking shutdown if Ollama is hung.
|
|
1156
|
+
if (pendingEmbeds.size > 0) {
|
|
1157
|
+
try {
|
|
1158
|
+
await Promise.race([
|
|
1159
|
+
Promise.allSettled([...pendingEmbeds]),
|
|
1160
|
+
new Promise(r => setTimeout(r, 5_000)),
|
|
1161
|
+
]);
|
|
1162
|
+
} catch { /* best effort */ }
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// JSONL export + DB close (fast — synchronous I/O, ~50ms)
|
|
1166
|
+
if (store) {
|
|
1167
|
+
try {
|
|
1168
|
+
const fsSync = await import("node:fs");
|
|
1169
|
+
const jsonlPath = path.join(memoryDir, "facts.jsonl");
|
|
1170
|
+
const jsonl = store.exportToJsonl();
|
|
1171
|
+
writeJsonlIfChanged(fsSync, jsonlPath, jsonl);
|
|
1172
|
+
} catch {
|
|
1173
|
+
// Best effort — don't block shutdown
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
store?.close(); globalStore?.close();
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
// --- Local-model compaction ---
|
|
1181
|
+
// Two modes:
|
|
1182
|
+
// 1. compactionLocalFirst=true: intercept ALL compactions, try local first.
|
|
1183
|
+
// 2. compactionLocalFirst=false (default): only intercept when useLocalCompaction is set
|
|
1184
|
+
// after a cloud failure or explicit local policy choice.
|
|
1185
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
1186
|
+
if (!shouldInterceptCompaction(sharedState.effort?.compaction, config, useLocalCompaction)) return;
|
|
1187
|
+
useLocalCompaction = false; // consume the flag if it was set
|
|
1188
|
+
|
|
1189
|
+
const prep = event.preparation;
|
|
1190
|
+
if (!prep || prep.messagesToSummarize.length === 0) return;
|
|
1191
|
+
|
|
1192
|
+
// Get the intelligent fallback chain
|
|
1193
|
+
const fallbackChain = resolveCompactionFallbackChain(ctx, config);
|
|
1194
|
+
|
|
1195
|
+
if (ctx.hasUI) {
|
|
1196
|
+
const isRetry = !config.compactionLocalFirst;
|
|
1197
|
+
const firstTier = fallbackChain[0]?.label || "Unknown";
|
|
1198
|
+
ctx.ui.notify(
|
|
1199
|
+
isRetry
|
|
1200
|
+
? `Cloud compaction failed — trying intelligent fallback: ${firstTier}`
|
|
1201
|
+
: `Intelligent compaction: ${firstTier} → GPT-5.3 → Haiku fallback chain`,
|
|
1202
|
+
isRetry ? "warning" : "info",
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Try each tier in the fallback chain
|
|
1207
|
+
for (const [index, tier] of fallbackChain.entries()) {
|
|
1208
|
+
console.log(`[project-memory] Trying compaction tier ${index + 1}/${fallbackChain.length}: ${tier.label} (${tier.timeout}ms timeout)`);
|
|
1209
|
+
|
|
1210
|
+
const result = await tryCompactionWithTier(
|
|
1211
|
+
tier.tier,
|
|
1212
|
+
tier.timeout,
|
|
1213
|
+
tier.label,
|
|
1214
|
+
prep,
|
|
1215
|
+
event.customInstructions,
|
|
1216
|
+
event.signal,
|
|
1217
|
+
ctx
|
|
1218
|
+
);
|
|
1219
|
+
|
|
1220
|
+
if (result) {
|
|
1221
|
+
console.log(`[project-memory] Compaction succeeded with ${tier.label}`);
|
|
1222
|
+
return {
|
|
1223
|
+
compaction: {
|
|
1224
|
+
summary: result.summary,
|
|
1225
|
+
firstKeptEntryId: prep.firstKeptEntryId,
|
|
1226
|
+
tokensBefore: prep.tokensBefore,
|
|
1227
|
+
details: result.details,
|
|
1228
|
+
},
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// If this was a cloud tier that returned null, it means we should fall through
|
|
1233
|
+
// to pi's core compaction with that model instead of continuing the chain.
|
|
1234
|
+
if (tier.tier !== "local" && result === null) {
|
|
1235
|
+
console.log(`[project-memory] ${tier.label} requesting fallthrough to pi core compaction`);
|
|
1236
|
+
return; // Let pi core handle it with the selected model
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
console.log(`[project-memory] ${tier.label} compaction failed, trying next tier`);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// All tiers in the chain failed
|
|
1243
|
+
console.error(`[project-memory] All compaction tiers failed. Fallback chain: ${fallbackChain.map(t => t.label).join(" → ")}`);
|
|
1244
|
+
return; // Let pi core attempt compaction as final fallback
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
1248
|
+
postCompaction = true;
|
|
1249
|
+
|
|
1250
|
+
if (store && !triggerState.isRunning) {
|
|
1251
|
+
triggerState.isRunning = true;
|
|
1252
|
+
try {
|
|
1253
|
+
await runExtractionCycle(ctx, config);
|
|
1254
|
+
const usage = ctx.getContextUsage();
|
|
1255
|
+
triggerState.lastExtractedTokens = usage?.tokens ?? 0;
|
|
1256
|
+
triggerState.isInitialized = true;
|
|
1257
|
+
consecutiveExtractionFailures = 0;
|
|
1258
|
+
} catch {
|
|
1259
|
+
consecutiveExtractionFailures++;
|
|
1260
|
+
} finally {
|
|
1261
|
+
triggerState.isRunning = false;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
triggerState.toolCallsSinceExtract = 0;
|
|
1266
|
+
triggerState.manualStoresSinceExtract = 0;
|
|
1267
|
+
compactionWarned = false;
|
|
1268
|
+
autoCompacted = false;
|
|
1269
|
+
compactionRetryCount = 0; // successful compaction resets retry counter
|
|
1270
|
+
|
|
1271
|
+
// Resume the agent after ANY compaction (pi-initiated or extension-initiated).
|
|
1272
|
+
// Pi's built-in auto-compaction at threshold doesn't resume the agent — it just
|
|
1273
|
+
// compacts and goes idle. Without this, the agent hangs after compaction.
|
|
1274
|
+
//
|
|
1275
|
+
// IMPORTANT: Use setTimeout to avoid reentrancy. In pi's auto-compaction path,
|
|
1276
|
+
// session_compact fires from within _handleAgentEvent(agent_end). Calling
|
|
1277
|
+
// agent.prompt() synchronously here would cause reentrant event processing
|
|
1278
|
+
// (agent_start inside agent_end handling). The 100ms delay matches pi's own
|
|
1279
|
+
// pattern for post-compaction resume (see _runAutoCompaction's setTimeout).
|
|
1280
|
+
setTimeout(() => {
|
|
1281
|
+
pi.sendMessage(
|
|
1282
|
+
{
|
|
1283
|
+
customType: "compaction-resume",
|
|
1284
|
+
content: [
|
|
1285
|
+
"Context was compacted to free space. Your project memory and working memory are intact.",
|
|
1286
|
+
"",
|
|
1287
|
+
"**Resume your previous task.** The compaction summary above preserves your progress.",
|
|
1288
|
+
"If you need to recall specific facts, use `memory_recall(query)` for targeted retrieval.",
|
|
1289
|
+
].join("\n"),
|
|
1290
|
+
display: false,
|
|
1291
|
+
},
|
|
1292
|
+
{ triggerTurn: true },
|
|
1293
|
+
);
|
|
1294
|
+
}, 100);
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
// --- Compaction retry with local model fallback ---
|
|
1298
|
+
// Pi's own auto-compaction handles triggering at its threshold (~contextWindow - reserveTokens).
|
|
1299
|
+
// When cloud compaction fails (overloaded_error), pi doesn't retry. On the next
|
|
1300
|
+
// tool_execution_end, if context is still above our threshold, we trigger compaction
|
|
1301
|
+
// ourselves with the local model fallback enabled.
|
|
1302
|
+
//
|
|
1303
|
+
// Flow: pi auto-compact (cloud) → fails → next tool_execution_end → still over threshold
|
|
1304
|
+
// → we set useLocalCompaction=true → ctx.compact() → session_before_compact
|
|
1305
|
+
// → local model generates summary → success → resume relay
|
|
1306
|
+
|
|
1307
|
+
// --- Extraction cycle ---
|
|
1308
|
+
|
|
1309
|
+
async function runExtractionCycle(ctx: ExtensionContext, cfg: MemoryConfig): Promise<void> {
|
|
1310
|
+
if (!store) return;
|
|
1311
|
+
|
|
1312
|
+
const mind = activeMind();
|
|
1313
|
+
const currentFacts = store.getActiveFacts(mind);
|
|
1314
|
+
|
|
1315
|
+
const branch = ctx.sessionManager.getBranch();
|
|
1316
|
+
const messages = branch
|
|
1317
|
+
.filter((e): e is SessionMessageEntry => e.type === "message")
|
|
1318
|
+
.map((e) => e.message);
|
|
1319
|
+
|
|
1320
|
+
const recentMessages = messages.slice(-30);
|
|
1321
|
+
if (recentMessages.length === 0) return;
|
|
1322
|
+
|
|
1323
|
+
// Re-apply effort override at call-time so mid-session /effort switches take effect
|
|
1324
|
+
// without requiring a session restart.
|
|
1325
|
+
const activeCfg = applyEffortToCfg(cfg);
|
|
1326
|
+
|
|
1327
|
+
const serialized = serializeConversation(convertToLlm(recentMessages));
|
|
1328
|
+
const rawOutput = await runExtractionV2(ctx.cwd, currentFacts, serialized, activeCfg);
|
|
1329
|
+
|
|
1330
|
+
if (!rawOutput.trim()) return;
|
|
1331
|
+
|
|
1332
|
+
const actions = parseExtractionOutput(rawOutput);
|
|
1333
|
+
if (actions.length > 0) {
|
|
1334
|
+
const result = store.processExtraction(mind, actions);
|
|
1335
|
+
|
|
1336
|
+
// Embed newly created facts (tracked fire-and-forget — shutdown awaits these)
|
|
1337
|
+
if (result.newFactIds.length > 0 && embeddingAvailable) {
|
|
1338
|
+
for (const id of result.newFactIds) {
|
|
1339
|
+
trackEmbed(embedFact(id).catch(() => {}));
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Phase 2: Global extraction — if new facts were created and global store exists
|
|
1344
|
+
if (result.newFactIds.length > 0 && globalStore && cfg.globalExtractionEnabled) {
|
|
1345
|
+
try {
|
|
1346
|
+
const newFacts = result.newFactIds
|
|
1347
|
+
.map(id => store!.getFact(id))
|
|
1348
|
+
.filter((f): f is NonNullable<typeof f> => f !== null);
|
|
1349
|
+
|
|
1350
|
+
if (newFacts.length > 0) {
|
|
1351
|
+
const globalMind = globalStore.getActiveMind() ?? "default";
|
|
1352
|
+
const globalFacts = globalStore.getActiveFacts(globalMind);
|
|
1353
|
+
const globalEdges = globalStore.getActiveEdges();
|
|
1354
|
+
|
|
1355
|
+
const globalRawOutput = await runGlobalExtraction(
|
|
1356
|
+
ctx.cwd, newFacts, globalFacts, globalEdges, activeCfg,
|
|
1357
|
+
);
|
|
1358
|
+
|
|
1359
|
+
if (globalRawOutput.trim()) {
|
|
1360
|
+
const globalActions = parseExtractionOutput(globalRawOutput);
|
|
1361
|
+
// Process fact actions first — observe creates new global facts
|
|
1362
|
+
// that connect actions can then reference
|
|
1363
|
+
const factActions = globalActions.filter(a => a.type !== "connect");
|
|
1364
|
+
const edgeActions = globalActions.filter(a => a.type === "connect");
|
|
1365
|
+
|
|
1366
|
+
if (factActions.length > 0) {
|
|
1367
|
+
globalStore.processExtraction(globalMind, factActions);
|
|
1368
|
+
}
|
|
1369
|
+
// Edge actions reference global fact IDs (from existing global facts
|
|
1370
|
+
// or from facts just promoted via observe in the same extraction).
|
|
1371
|
+
// processEdges validates both endpoints exist before storing.
|
|
1372
|
+
if (edgeActions.length > 0) {
|
|
1373
|
+
globalStore.processEdges(edgeActions);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
// Global extraction is best-effort — don't fail the whole cycle
|
|
1379
|
+
const msg = (err as Error).message ?? "";
|
|
1380
|
+
const isRateLimit = /\b429\b/.test(msg) || msg.includes("rate_limit_error");
|
|
1381
|
+
if (isRateLimit) {
|
|
1382
|
+
// Rate limited — silently skip, will retry next cycle
|
|
1383
|
+
} else if (ctx.hasUI) {
|
|
1384
|
+
const short = msg.length > 120 ? msg.slice(0, 120) + "…" : msg;
|
|
1385
|
+
ctx.ui.notify(`Global extraction failed: ${short}`, "warning");
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
updateStatus(ctx);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* Run a memory refresh — extraction with no conversation context.
|
|
1396
|
+
* Used by /memory refresh command and __refresh__ menu action.
|
|
1397
|
+
*/
|
|
1398
|
+
function startRefresh(ctx: ExtensionCommandContext): void {
|
|
1399
|
+
ctx.ui.notify("Running extraction to prune and consolidate memory (15–60s)…", "info");
|
|
1400
|
+
activeExtractionPromise = (async () => {
|
|
1401
|
+
try {
|
|
1402
|
+
triggerState.isRunning = true;
|
|
1403
|
+
const mind = activeMind();
|
|
1404
|
+
const currentFacts = store!.getActiveFacts(mind);
|
|
1405
|
+
const rawOutput = await runExtractionV2(
|
|
1406
|
+
ctx.cwd,
|
|
1407
|
+
currentFacts,
|
|
1408
|
+
`[Memory refresh requested. Review existing facts for accuracy and relevance. Archive stale or redundant facts. No new conversation context.]`,
|
|
1409
|
+
applyEffortToCfg(config),
|
|
1410
|
+
);
|
|
1411
|
+
if (rawOutput.trim()) {
|
|
1412
|
+
const actions = parseExtractionOutput(rawOutput);
|
|
1413
|
+
if (actions.length > 0) {
|
|
1414
|
+
const result = store!.processExtraction(mind, actions);
|
|
1415
|
+
ctx.ui.notify(
|
|
1416
|
+
`Memory refreshed: ${result.added} added, ${result.reinforced} reinforced`,
|
|
1417
|
+
"info",
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
} else {
|
|
1421
|
+
ctx.ui.notify("No changes needed", "info");
|
|
1422
|
+
}
|
|
1423
|
+
updateStatus(ctx);
|
|
1424
|
+
} catch (err: any) {
|
|
1425
|
+
ctx.ui.notify(`Refresh failed: ${err.message}`, "error");
|
|
1426
|
+
} finally {
|
|
1427
|
+
triggerState.isRunning = false;
|
|
1428
|
+
}
|
|
1429
|
+
})();
|
|
1430
|
+
activeExtractionPromise.finally(() => { activeExtractionPromise = null; });
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// --- Context Injection ---
|
|
1434
|
+
|
|
1435
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
1436
|
+
drainLifecycleCandidateQueue(ctx);
|
|
1437
|
+
drainFactArchiveQueue();
|
|
1438
|
+
if (!store) return;
|
|
1439
|
+
if (!firstTurn && !postCompaction) return;
|
|
1440
|
+
|
|
1441
|
+
firstTurn = false;
|
|
1442
|
+
postCompaction = false;
|
|
1443
|
+
|
|
1444
|
+
const mind = activeMind();
|
|
1445
|
+
const factCount = store.countActiveFacts(mind);
|
|
1446
|
+
const mindLabel = mind !== "default" ? ` (mind: ${mind})` : "";
|
|
1447
|
+
|
|
1448
|
+
if (factCount <= 3) {
|
|
1449
|
+
const content = [
|
|
1450
|
+
`Project memory initialized${mindLabel} (${factCount} facts stored).`,
|
|
1451
|
+
"Use **memory_store** to persist important discoveries as you work",
|
|
1452
|
+
"(architecture decisions, constraints, patterns, known issues).",
|
|
1453
|
+
"Facts persist across sessions and will be available next time.",
|
|
1454
|
+
].join(" ");
|
|
1455
|
+
const usage = ctx.getContextUsage();
|
|
1456
|
+
const metrics = createMemoryInjectionMetrics({
|
|
1457
|
+
mode: "tiny",
|
|
1458
|
+
projectFactCount: factCount,
|
|
1459
|
+
payloadChars: content.length,
|
|
1460
|
+
baselineContextTokens: usage?.tokens ?? null,
|
|
1461
|
+
userPromptTokensEstimate: estimateTokensFromChars((event as any).prompt ?? ""),
|
|
1462
|
+
});
|
|
1463
|
+
sharedState.memoryTokenEstimate = metrics.estimatedTokens;
|
|
1464
|
+
sharedState.lastMemoryInjection = metrics;
|
|
1465
|
+
pendingInjectionCalibration = {
|
|
1466
|
+
baselineContextTokens: metrics.baselineContextTokens ?? null,
|
|
1467
|
+
userPromptTokensEstimate: metrics.userPromptTokensEstimate ?? 0,
|
|
1468
|
+
};
|
|
1469
|
+
return {
|
|
1470
|
+
message: {
|
|
1471
|
+
customType: "project-memory",
|
|
1472
|
+
content,
|
|
1473
|
+
display: false,
|
|
1474
|
+
},
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// --- Unified Priority-Ordered Context Pipeline ---
|
|
1479
|
+
//
|
|
1480
|
+
// Single pipeline replaces the old dual bulk/semantic split. Facts are
|
|
1481
|
+
// selected in priority order and rendered within a character budget.
|
|
1482
|
+
// When embeddings are unavailable, tiers 4a (FTS5) and 5/6 naturally
|
|
1483
|
+
// fill the budget — no separate codepath needed.
|
|
1484
|
+
//
|
|
1485
|
+
// Priority tiers:
|
|
1486
|
+
// 1. Working memory (pinned facts) — highest priority
|
|
1487
|
+
// 2. Decisions (top N by confidence) — structural anchor
|
|
1488
|
+
// 3. Architecture (top N by recency) — structural anchor
|
|
1489
|
+
// 4. Hybrid search (FTS5 + embedding via RRF) — query-relevant
|
|
1490
|
+
// 5. Structural fill (top 2 per remaining section) — coverage
|
|
1491
|
+
// 6. Recency fill (most recently reinforced) — fills remaining budget
|
|
1492
|
+
|
|
1493
|
+
const userText = (event as any).prompt ?? "";
|
|
1494
|
+
const usage = ctx.getContextUsage();
|
|
1495
|
+
|
|
1496
|
+
// Budget: reserve space for other context (design-tree, system prompt, etc.)
|
|
1497
|
+
// Use 15% of total context as memory budget, with a floor and ceiling.
|
|
1498
|
+
// Estimate total tokens from current usage: tokens / (percent/100).
|
|
1499
|
+
const usedTokens = usage?.tokens ?? 0;
|
|
1500
|
+
const usedPercent = usage?.percent ?? 0;
|
|
1501
|
+
const estimatedTotalTokens = usedPercent > 0
|
|
1502
|
+
? Math.round(usedTokens / (usedPercent / 100))
|
|
1503
|
+
: 200_000; // safe default
|
|
1504
|
+
const MAX_MEMORY_CHARS = Math.min(
|
|
1505
|
+
Math.max(Math.round(estimatedTotalTokens * 0.15 * 4), 4_000), // 15% of context, min 4K chars
|
|
1506
|
+
16_000, // absolute ceiling
|
|
1507
|
+
);
|
|
1508
|
+
|
|
1509
|
+
const allFacts = store.getActiveFacts(mind);
|
|
1510
|
+
const injectedIds = new Set<string>();
|
|
1511
|
+
const injectedFacts: Fact[] = [];
|
|
1512
|
+
let currentChars = 0;
|
|
1513
|
+
let injectedWorkingMemoryFactCount = 0;
|
|
1514
|
+
let injectedSemanticHitCount = 0;
|
|
1515
|
+
|
|
1516
|
+
/** Measure a fact's rendered line length */
|
|
1517
|
+
function factCharCost(f: Fact): number {
|
|
1518
|
+
return f.content.length + 20; // bullet + date + newline overhead
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/** Add a fact if it fits the budget and isn't already included */
|
|
1522
|
+
function tryAdd(f: Fact): boolean {
|
|
1523
|
+
if (injectedIds.has(f.id)) return false;
|
|
1524
|
+
const cost = factCharCost(f);
|
|
1525
|
+
if (currentChars + cost > MAX_MEMORY_CHARS) return false;
|
|
1526
|
+
injectedFacts.push(f);
|
|
1527
|
+
injectedIds.add(f.id);
|
|
1528
|
+
currentChars += cost;
|
|
1529
|
+
return true;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// --- Tier 1: Working memory (pinned facts) — always included first ---
|
|
1533
|
+
const wmFacts = [...workingMemory]
|
|
1534
|
+
.map(id => store!.getFact(id))
|
|
1535
|
+
.filter((f): f is Fact => f !== null && f.status === "active");
|
|
1536
|
+
for (const f of wmFacts) {
|
|
1537
|
+
tryAdd(f);
|
|
1538
|
+
injectedWorkingMemoryFactCount++;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// --- Tier 2: Decisions (top 8 by confidence) — structural anchor ---
|
|
1542
|
+
const decisionFacts = allFacts
|
|
1543
|
+
.filter(f => f.section === "Decisions")
|
|
1544
|
+
.sort((a, b) => b.confidence - a.confidence)
|
|
1545
|
+
.slice(0, 8);
|
|
1546
|
+
for (const f of decisionFacts) tryAdd(f);
|
|
1547
|
+
|
|
1548
|
+
// --- Tier 3: Architecture (top 8 by recency) — structural anchor ---
|
|
1549
|
+
const archFacts = allFacts
|
|
1550
|
+
.filter(f => f.section === "Architecture")
|
|
1551
|
+
.sort((a, b) => new Date(b.last_reinforced).getTime() - new Date(a.last_reinforced).getTime())
|
|
1552
|
+
.slice(0, 8);
|
|
1553
|
+
for (const f of archFacts) tryAdd(f);
|
|
1554
|
+
|
|
1555
|
+
// --- Tier 4: Hybrid search (FTS5 + embedding via RRF) ---
|
|
1556
|
+
// Uses hybridSearch which combines FTS5 keyword matching with semantic
|
|
1557
|
+
// embedding search. When embeddings are unavailable, degrades to FTS5-only.
|
|
1558
|
+
if (userText.length > 5 && currentChars < MAX_MEMORY_CHARS) {
|
|
1559
|
+
const queryVec = await embedText(userText);
|
|
1560
|
+
const hybridHits = store.hybridSearch(userText, queryVec, mind, {
|
|
1561
|
+
k: 15,
|
|
1562
|
+
minSimilarity: 0.3,
|
|
1563
|
+
});
|
|
1564
|
+
for (const f of hybridHits) {
|
|
1565
|
+
if (!tryAdd(f)) break; // budget exhausted
|
|
1566
|
+
injectedSemanticHitCount++;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// --- Tier 5: Structural fill — top 2 per remaining section ---
|
|
1571
|
+
// Ensures every section has representation even without a matching query.
|
|
1572
|
+
// This is what bulk mode got right that old semantic mode missed.
|
|
1573
|
+
const FILL_SECTIONS = ["Constraints", "Known Issues", "Patterns & Conventions", "Specs", "Recent Work"] as const;
|
|
1574
|
+
for (const section of FILL_SECTIONS) {
|
|
1575
|
+
if (currentChars >= MAX_MEMORY_CHARS) break;
|
|
1576
|
+
const sectionFacts = allFacts
|
|
1577
|
+
.filter(f => f.section === section)
|
|
1578
|
+
.sort((a, b) => b.confidence - a.confidence)
|
|
1579
|
+
.slice(0, 3);
|
|
1580
|
+
for (const f of sectionFacts) {
|
|
1581
|
+
if (!tryAdd(f)) break;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// --- Tier 6: Recency fill — most recently reinforced not yet included ---
|
|
1586
|
+
if (currentChars < MAX_MEMORY_CHARS) {
|
|
1587
|
+
const recentFacts = [...allFacts]
|
|
1588
|
+
.sort((a, b) => new Date(b.last_reinforced).getTime() - new Date(a.last_reinforced).getTime())
|
|
1589
|
+
.slice(0, 20);
|
|
1590
|
+
for (const f of recentFacts) {
|
|
1591
|
+
if (!tryAdd(f)) break;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const injectedProjectFactCount = injectedFacts.length;
|
|
1596
|
+
const rendered = store.renderFactList(injectedFacts, { showIds: false });
|
|
1597
|
+
|
|
1598
|
+
// --- Global knowledge: semantic-gated, only when query is available ---
|
|
1599
|
+
let globalSection = "";
|
|
1600
|
+
let injectedGlobalFactCount = 0;
|
|
1601
|
+
if (globalStore && userText.length > 10) {
|
|
1602
|
+
const globalMind = globalStore.getActiveMind() ?? "default";
|
|
1603
|
+
const globalFactCount = globalStore.countActiveFacts(globalMind);
|
|
1604
|
+
if (globalFactCount > 0) {
|
|
1605
|
+
const queryVec = await embedText(userText);
|
|
1606
|
+
if (queryVec) {
|
|
1607
|
+
const globalHits = globalStore.semanticSearch(queryVec, globalMind, { k: 6, minSimilarity: 0.45 });
|
|
1608
|
+
if (globalHits.length > 0) {
|
|
1609
|
+
injectedGlobalFactCount = globalHits.length;
|
|
1610
|
+
const globalRendered = globalStore.renderFactList(globalHits, { showIds: false });
|
|
1611
|
+
globalSection = `\n\n<!-- Global Knowledge — cross-project facts and connections -->\n${globalRendered}`;
|
|
1612
|
+
}
|
|
1613
|
+
} else {
|
|
1614
|
+
// Embeddings unavailable — use FTS5 for global search
|
|
1615
|
+
const globalHits = globalStore.searchFacts(userText, globalMind).slice(0, 4);
|
|
1616
|
+
if (globalHits.length > 0) {
|
|
1617
|
+
injectedGlobalFactCount = globalHits.length;
|
|
1618
|
+
const globalRendered = globalStore.renderFactList(globalHits, { showIds: false });
|
|
1619
|
+
globalSection = `\n\n<!-- Global Knowledge — cross-project facts and connections -->\n${globalRendered}`;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// --- Episodes: 1 most recent ---
|
|
1626
|
+
let episodeSection = "";
|
|
1627
|
+
let injectedEpisodeCount = 0;
|
|
1628
|
+
const episodeCount = store.countEpisodes(mind);
|
|
1629
|
+
if (episodeCount > 0) {
|
|
1630
|
+
const recentEpisodes = store.getEpisodes(mind, 1);
|
|
1631
|
+
if (recentEpisodes.length > 0) {
|
|
1632
|
+
injectedEpisodeCount = recentEpisodes.length;
|
|
1633
|
+
const episodeLines = recentEpisodes.map(e =>
|
|
1634
|
+
`### ${e.date}: ${e.title}\n${e.narrative}`
|
|
1635
|
+
);
|
|
1636
|
+
episodeSection = `\n\n## Recent Sessions\n_Episodic memory — what happened and why_\n\n${episodeLines.join("\n\n")}`;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
const memoryTools = embeddingAvailable
|
|
1641
|
+
? "Use **memory_recall(query)** to semantically search for specific knowledge. " +
|
|
1642
|
+
"Use **memory_store** to persist important discoveries. " +
|
|
1643
|
+
"Use **memory_episodes(query)** to search session narratives."
|
|
1644
|
+
: "Use **memory_query** to read accumulated knowledge about this project. " +
|
|
1645
|
+
"Use **memory_store** to persist important discoveries (architecture decisions, constraints, patterns, known issues). " +
|
|
1646
|
+
"Use **memory_search_archive** to search older archived facts.";
|
|
1647
|
+
|
|
1648
|
+
// Context pressure — continuous gradient from onset through warning
|
|
1649
|
+
let pressureWarning = "";
|
|
1650
|
+
if (!autoCompacted) {
|
|
1651
|
+
const pct = usage?.percent != null ? Math.round(usage.percent) : null;
|
|
1652
|
+
if (pct !== null) {
|
|
1653
|
+
const pressure = computeDegeneracyPressure(
|
|
1654
|
+
pct,
|
|
1655
|
+
config.pressureOnsetPercent,
|
|
1656
|
+
config.compactionWarningPercent,
|
|
1657
|
+
);
|
|
1658
|
+
const guidance = pressureGuidance(pressure, pct);
|
|
1659
|
+
if (guidance) {
|
|
1660
|
+
pressureWarning = `\n\n${guidance}` +
|
|
1661
|
+
(compactionWarned
|
|
1662
|
+
? ` Auto-compaction triggers at ${config.compactionAutoPercent}%.`
|
|
1663
|
+
: "");
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// Proactive startup payload — prepend on firstTurn if available.
|
|
1669
|
+
const startupSection = startupInjectionPayload
|
|
1670
|
+
? `\n\n${startupInjectionPayload}`
|
|
1671
|
+
: "";
|
|
1672
|
+
startupInjectionPayload = null; // consume once
|
|
1673
|
+
|
|
1674
|
+
const budgetNote = currentChars >= MAX_MEMORY_CHARS
|
|
1675
|
+
? ` (budget-capped at ~${Math.round(MAX_MEMORY_CHARS / 1000)}K chars)`
|
|
1676
|
+
: "";
|
|
1677
|
+
|
|
1678
|
+
const injectionContent = [
|
|
1679
|
+
`Project memory available${mindLabel} (${factCount} facts from this and previous sessions).${budgetNote}`,
|
|
1680
|
+
memoryTools + "\n\n",
|
|
1681
|
+
rendered,
|
|
1682
|
+
startupSection,
|
|
1683
|
+
episodeSection,
|
|
1684
|
+
globalSection,
|
|
1685
|
+
pressureWarning,
|
|
1686
|
+
].join(" ");
|
|
1687
|
+
|
|
1688
|
+
const injectionMode: MemoryInjectionMode = "semantic"; // unified pipeline is always "semantic"
|
|
1689
|
+
const metrics = createMemoryInjectionMetrics({
|
|
1690
|
+
mode: injectionMode,
|
|
1691
|
+
projectFactCount: injectedProjectFactCount,
|
|
1692
|
+
edgeCount: 0,
|
|
1693
|
+
workingMemoryFactCount: injectedWorkingMemoryFactCount,
|
|
1694
|
+
semanticHitCount: injectedSemanticHitCount,
|
|
1695
|
+
episodeCount: injectedEpisodeCount,
|
|
1696
|
+
globalFactCount: injectedGlobalFactCount,
|
|
1697
|
+
payloadChars: injectionContent.length,
|
|
1698
|
+
baselineContextTokens: usage?.tokens ?? null,
|
|
1699
|
+
userPromptTokensEstimate: estimateTokensFromChars(userText),
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
sharedState.memoryTokenEstimate = metrics.estimatedTokens;
|
|
1703
|
+
sharedState.lastMemoryInjection = metrics;
|
|
1704
|
+
pendingInjectionCalibration = {
|
|
1705
|
+
baselineContextTokens: metrics.baselineContextTokens ?? null,
|
|
1706
|
+
userPromptTokensEstimate: metrics.userPromptTokensEstimate ?? 0,
|
|
1707
|
+
};
|
|
1708
|
+
|
|
1709
|
+
return {
|
|
1710
|
+
message: {
|
|
1711
|
+
customType: "project-memory",
|
|
1712
|
+
content: injectionContent,
|
|
1713
|
+
display: false,
|
|
1714
|
+
},
|
|
1715
|
+
};
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
pi.on("agent_end", async (event, _ctx) => {
|
|
1719
|
+
if (!pendingInjectionCalibration) return;
|
|
1720
|
+
const lastAssistant = [...event.messages].reverse().find((msg: any) =>
|
|
1721
|
+
msg.role === "assistant" && msg.stopReason !== "aborted" && msg.stopReason !== "error" && msg.usage,
|
|
1722
|
+
) as any;
|
|
1723
|
+
if (!lastAssistant?.usage || !sharedState.lastMemoryInjection) return;
|
|
1724
|
+
|
|
1725
|
+
const observedInputTokens = lastAssistant.usage.input ?? 0;
|
|
1726
|
+
const baselineContextTokens = pendingInjectionCalibration.baselineContextTokens;
|
|
1727
|
+
const userPromptTokensEstimate = pendingInjectionCalibration.userPromptTokensEstimate;
|
|
1728
|
+
const inferredAdditionalPromptTokens = baselineContextTokens !== null
|
|
1729
|
+
? Math.max(0, observedInputTokens - baselineContextTokens - userPromptTokensEstimate)
|
|
1730
|
+
: null;
|
|
1731
|
+
const estimatedVsObservedDelta = inferredAdditionalPromptTokens !== null
|
|
1732
|
+
? sharedState.lastMemoryInjection.estimatedTokens - inferredAdditionalPromptTokens
|
|
1733
|
+
: null;
|
|
1734
|
+
|
|
1735
|
+
sharedState.lastMemoryInjection = {
|
|
1736
|
+
...sharedState.lastMemoryInjection,
|
|
1737
|
+
observedInputTokens,
|
|
1738
|
+
inferredAdditionalPromptTokens,
|
|
1739
|
+
estimatedVsObservedDelta,
|
|
1740
|
+
} satisfies MemoryInjectionMetrics;
|
|
1741
|
+
|
|
1742
|
+
pendingInjectionCalibration = null;
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
// --- Task-Completion Facts: capture write/edit args before execution ---
|
|
1746
|
+
// We listen to tool_call (pre-execution) to grab the file path from input,
|
|
1747
|
+
// then use tool_execution_end to confirm success and store the "Recent Work" fact.
|
|
1748
|
+
|
|
1749
|
+
pi.on("tool_call", (event, _ctx) => {
|
|
1750
|
+
const name = (event as any).toolName as string | undefined;
|
|
1751
|
+
if (name === "write" || name === "edit") {
|
|
1752
|
+
const input = (event as any).input as Record<string, unknown> | undefined;
|
|
1753
|
+
const filePath = (input?.path ?? input?.file_path) as string | undefined;
|
|
1754
|
+
const toolCallId = (event as any).toolCallId as string | undefined;
|
|
1755
|
+
if (filePath && toolCallId) {
|
|
1756
|
+
pendingWriteEditArgs.set(toolCallId, { toolName: name, path: filePath });
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
// --- Background Extraction Triggers ---
|
|
1762
|
+
|
|
1763
|
+
pi.on("tool_execution_end", async (event, ctx) => {
|
|
1764
|
+
if (!store) return;
|
|
1765
|
+
|
|
1766
|
+
triggerState.toolCallsSinceExtract++;
|
|
1767
|
+
|
|
1768
|
+
// --- Task-completion facts (Recent Work section) ---
|
|
1769
|
+
// Fire-and-forget, non-blocking. Only file writes/edits that succeed.
|
|
1770
|
+
if (!event.isError && (event.toolName === "write" || event.toolName === "edit")) {
|
|
1771
|
+
const pending = pendingWriteEditArgs.get(event.toolCallId);
|
|
1772
|
+
const filePath = pending?.path;
|
|
1773
|
+
if (filePath) {
|
|
1774
|
+
pendingWriteEditArgs.delete(event.toolCallId);
|
|
1775
|
+
const action = event.toolName === "write" ? "Wrote" : "Edited";
|
|
1776
|
+
const shortPath = filePath.replace(process.cwd() + "/", "").replace(process.cwd(), "");
|
|
1777
|
+
const factContent = `${action} ${shortPath}`;
|
|
1778
|
+
|
|
1779
|
+
// Track for session telemetry
|
|
1780
|
+
if (event.toolName === "write") {
|
|
1781
|
+
sessionFilesWritten.push(shortPath);
|
|
1782
|
+
} else {
|
|
1783
|
+
sessionFilesEdited.push(shortPath);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// Store as "Recent Work" fact with fast decay (halfLife=2d)
|
|
1787
|
+
const mind = activeMind();
|
|
1788
|
+
try {
|
|
1789
|
+
store.storeFact({
|
|
1790
|
+
mind,
|
|
1791
|
+
section: "Recent Work" as any,
|
|
1792
|
+
content: factContent,
|
|
1793
|
+
source: "tool-call",
|
|
1794
|
+
decayProfile: "recent_work",
|
|
1795
|
+
});
|
|
1796
|
+
} catch {
|
|
1797
|
+
// Best effort — non-blocking
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
if (event.toolName === "memory_store" && !event.isError) {
|
|
1803
|
+
triggerState.manualStoresSinceExtract++;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const usage = ctx.getContextUsage();
|
|
1807
|
+
if (!usage) return;
|
|
1808
|
+
|
|
1809
|
+
// --- Context Pressure: Auto-compact ---
|
|
1810
|
+
// Pi's built-in auto-compaction triggers at ~92% (contextWindow - reserveTokens).
|
|
1811
|
+
// We trigger earlier at compactionAutoPercent (default 85%) as a safety net.
|
|
1812
|
+
//
|
|
1813
|
+
// With compactionLocalFirst=true (default): session_before_compact intercepts
|
|
1814
|
+
// all attempts and tries local first. Cloud is fallback if Ollama unavailable.
|
|
1815
|
+
// With compactionLocalFirst=false: first attempt uses cloud. On failure,
|
|
1816
|
+
// useLocalCompaction flag is set for the retry to route through local.
|
|
1817
|
+
const pct = usage.percent ?? 0;
|
|
1818
|
+
if (pct >= config.compactionAutoPercent && !autoCompacted && compactionRetryCount < config.compactionRetryLimit) {
|
|
1819
|
+
autoCompacted = true;
|
|
1820
|
+
const isRetry = compactionRetryCount > 0;
|
|
1821
|
+
if (isRetry) {
|
|
1822
|
+
useLocalCompaction = true; // Previous cloud attempt failed — use local model
|
|
1823
|
+
console.error(`[project-memory] Retrying compaction with local model (attempt ${compactionRetryCount + 1})`);
|
|
1824
|
+
}
|
|
1825
|
+
if (ctx.hasUI) {
|
|
1826
|
+
ctx.ui.notify(
|
|
1827
|
+
isRetry
|
|
1828
|
+
? `Retrying compaction via local model (attempt ${compactionRetryCount + 1})…`
|
|
1829
|
+
: `Context at ${Math.round(pct)}% — auto-compacting to preserve session continuity.`,
|
|
1830
|
+
"warning",
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
ctx.compact({
|
|
1834
|
+
customInstructions: "Session hit auto-compaction threshold. Preserve recent work context and any in-progress task state.",
|
|
1835
|
+
// Resume is handled centrally in session_compact handler (covers all compaction sources).
|
|
1836
|
+
onError: (err: Error) => {
|
|
1837
|
+
compactionRetryCount++;
|
|
1838
|
+
autoCompacted = false; // allow retry on next tool_execution_end
|
|
1839
|
+
console.error(`[project-memory] Compaction failed (attempt ${compactionRetryCount}/${config.compactionRetryLimit}): ${err.message}`);
|
|
1840
|
+
if (compactionRetryCount >= config.compactionRetryLimit && ctx.hasUI) {
|
|
1841
|
+
ctx.ui.notify("Compaction failed after max retries. Context may be degraded.", "error");
|
|
1842
|
+
}
|
|
1843
|
+
},
|
|
1844
|
+
});
|
|
1845
|
+
} else if (pct >= config.compactionWarningPercent && !compactionWarned) {
|
|
1846
|
+
// Mark warning — will be injected via before_agent_start
|
|
1847
|
+
compactionWarned = true;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
if (shouldExtract(triggerState, usage.tokens ?? 0, config, consecutiveExtractionFailures)) {
|
|
1851
|
+
activeExtractionPromise = (async () => {
|
|
1852
|
+
if (!store || triggerState.isRunning) return;
|
|
1853
|
+
triggerState.isRunning = true;
|
|
1854
|
+
try {
|
|
1855
|
+
await runExtractionCycle(ctx, config);
|
|
1856
|
+
const usage = ctx.getContextUsage();
|
|
1857
|
+
triggerState.lastExtractedTokens = usage?.tokens ?? 0;
|
|
1858
|
+
triggerState.toolCallsSinceExtract = 0;
|
|
1859
|
+
triggerState.manualStoresSinceExtract = 0;
|
|
1860
|
+
triggerState.isInitialized = true;
|
|
1861
|
+
consecutiveExtractionFailures = 0;
|
|
1862
|
+
} catch {
|
|
1863
|
+
consecutiveExtractionFailures++;
|
|
1864
|
+
} finally {
|
|
1865
|
+
triggerState.isRunning = false;
|
|
1866
|
+
}
|
|
1867
|
+
})();
|
|
1868
|
+
activeExtractionPromise.catch(() => {}).finally(() => { activeExtractionPromise = null; });
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
|
|
1872
|
+
// --- Lifecycle Candidate Ingestion API ---
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* Process a lifecycle candidate through the structured ingestion pipeline.
|
|
1876
|
+
* Entry point for design-tree, openspec, and cleave to emit lifecycle candidates.
|
|
1877
|
+
*
|
|
1878
|
+
* @param candidate - The lifecycle candidate to process
|
|
1879
|
+
* @returns Result indicating whether it was stored, reinforced, or deferred
|
|
1880
|
+
*/
|
|
1881
|
+
function ingestLifecycle(candidate: LifecycleCandidate): LifecycleCandidateResult {
|
|
1882
|
+
if (!store) {
|
|
1883
|
+
return {
|
|
1884
|
+
autoStored: false,
|
|
1885
|
+
duplicate: false,
|
|
1886
|
+
reason: "Project memory not initialized",
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
const mind = activeMind();
|
|
1891
|
+
return ingestLifecycleCandidate(store, mind, candidate);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* Process multiple lifecycle candidates in a batch.
|
|
1896
|
+
*
|
|
1897
|
+
* @param candidates - Array of lifecycle candidates to process
|
|
1898
|
+
* @returns Aggregated batch results
|
|
1899
|
+
*/
|
|
1900
|
+
function ingestLifecycleBatch(candidates: LifecycleCandidate[]): BatchIngestResult {
|
|
1901
|
+
if (!store) {
|
|
1902
|
+
return {
|
|
1903
|
+
autoStored: 0,
|
|
1904
|
+
reinforced: 0,
|
|
1905
|
+
rejected: 0,
|
|
1906
|
+
deferred: 0,
|
|
1907
|
+
factIds: [],
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
const mind = activeMind();
|
|
1912
|
+
return ingestLifecycleCandidatesBatch(store, mind, candidates);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// --- Lifecycle Candidate Queue Ingestion ---
|
|
1916
|
+
|
|
1917
|
+
function drainLifecycleCandidateQueue(ctx: ExtensionContext): void {
|
|
1918
|
+
if (!store) return;
|
|
1919
|
+
const queue = sharedState.lifecycleCandidateQueue ?? [];
|
|
1920
|
+
if (queue.length === 0) return;
|
|
1921
|
+
|
|
1922
|
+
sharedState.lifecycleCandidateQueue = [];
|
|
1923
|
+
|
|
1924
|
+
for (const payload of queue) {
|
|
1925
|
+
try {
|
|
1926
|
+
if (!Array.isArray(payload.candidates) || payload.candidates.length === 0) continue;
|
|
1927
|
+
|
|
1928
|
+
const mind = activeMind();
|
|
1929
|
+
const result = ingestLifecycleCandidatesBatch(store, mind, payload.candidates as LifecycleMemoryCandidate[]);
|
|
1930
|
+
|
|
1931
|
+
for (const factId of result.factIds) {
|
|
1932
|
+
addToWorkingMemory(factId);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
if (ctx.hasUI && (result.autoStored > 0 || result.reinforced > 0)) {
|
|
1936
|
+
const parts: string[] = [];
|
|
1937
|
+
if (result.autoStored > 0) parts.push(`+${result.autoStored} stored`);
|
|
1938
|
+
if (result.reinforced > 0) parts.push(`${result.reinforced} reinforced`);
|
|
1939
|
+
ctx.ui.notify(`Lifecycle memory (${payload.source}): ${parts.join(", ")}`, "info");
|
|
1940
|
+
}
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
console.error("[project-memory] Failed to ingest lifecycle candidates:", error);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
updateStatus(ctx);
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function drainFactArchiveQueue(): void {
|
|
1950
|
+
if (!store) return;
|
|
1951
|
+
const queue = sharedState.factArchiveQueue ?? [];
|
|
1952
|
+
if (queue.length === 0) return;
|
|
1953
|
+
|
|
1954
|
+
sharedState.factArchiveQueue = [];
|
|
1955
|
+
|
|
1956
|
+
const mind = activeMind();
|
|
1957
|
+
for (const contentPrefix of queue) {
|
|
1958
|
+
try {
|
|
1959
|
+
// Use prefix lookup (LIKE) instead of FTS5 to avoid syntax errors with special chars like '[', '(', etc.
|
|
1960
|
+
const results = store.findFactsByContentPrefix(contentPrefix, mind);
|
|
1961
|
+
for (const fact of results) {
|
|
1962
|
+
store.archiveFact(fact.id);
|
|
1963
|
+
}
|
|
1964
|
+
} catch (error) {
|
|
1965
|
+
console.error("[project-memory] Failed to archive fact by prefix:", error);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// --- Tools ---
|
|
1971
|
+
|
|
1972
|
+
pi.registerTool({
|
|
1973
|
+
name: "memory_query",
|
|
1974
|
+
label: "Project Memory",
|
|
1975
|
+
description: [
|
|
1976
|
+
"Read project memory — accumulated knowledge about this project's architecture,",
|
|
1977
|
+
"decisions, constraints, known issues, and patterns from this and previous sessions.",
|
|
1978
|
+
"Use when you need context about why something was done a certain way,",
|
|
1979
|
+
"known problems, or project conventions.",
|
|
1980
|
+
].join(" "),
|
|
1981
|
+
promptSnippet: "Read accumulated project knowledge (architecture, decisions, constraints, patterns)",
|
|
1982
|
+
promptGuidelines: [
|
|
1983
|
+
"Use memory_recall(query) for targeted semantic retrieval instead of loading all facts",
|
|
1984
|
+
"Use memory_query only when you need the complete picture — memory_recall is more efficient",
|
|
1985
|
+
"Use memory_store to persist important discoveries — facts survive across sessions",
|
|
1986
|
+
"Use memory_episodes(query) to retrieve session narratives for context about past work",
|
|
1987
|
+
],
|
|
1988
|
+
parameters: Type.Object({}),
|
|
1989
|
+
renderCall(_args: any, theme: any) {
|
|
1990
|
+
return sciCall("memory_query", "full read", theme);
|
|
1991
|
+
},
|
|
1992
|
+
renderResult(result: any, { expanded }: any, theme: any) {
|
|
1993
|
+
const d = result.details as { facts?: number; mind?: string } | undefined;
|
|
1994
|
+
const n = d?.facts ?? 0;
|
|
1995
|
+
const mind = d?.mind ?? "project";
|
|
1996
|
+
const summary = `${n} facts · ${mind}`;
|
|
1997
|
+
|
|
1998
|
+
if (expanded) {
|
|
1999
|
+
const text = result.content?.[0]?.text ?? "";
|
|
2000
|
+
// Parse section headers from rendered output
|
|
2001
|
+
const lines: string[] = [];
|
|
2002
|
+
const sections = text.split(/\n## /).filter(Boolean);
|
|
2003
|
+
for (const sec of sections) {
|
|
2004
|
+
const heading = sec.split("\n")[0].replace(/^#+\s*/, "").trim();
|
|
2005
|
+
const bullets = sec.split("\n").filter((l: string) => l.startsWith("- "));
|
|
2006
|
+
if (heading && bullets.length > 0) {
|
|
2007
|
+
lines.push(theme.fg("accent", heading) + theme.fg("dim", ` · ${bullets.length}`));
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
if (lines.length === 0) lines.push(theme.fg("muted", "empty"));
|
|
2011
|
+
return sciExpanded(lines, summary, theme);
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
return sciOk(summary, theme);
|
|
2015
|
+
},
|
|
2016
|
+
async execute(_toolCallId: string, _params: any, _signal: any, _onUpdate: any, _ctx: any): Promise<any> {
|
|
2017
|
+
if (!store) {
|
|
2018
|
+
return { content: [{ type: "text", text: "Project memory not initialized." }] };
|
|
2019
|
+
}
|
|
2020
|
+
const mind = activeMind();
|
|
2021
|
+
const rendered = store.renderForInjection(mind, { showIds: true });
|
|
2022
|
+
const factCount = store.countActiveFacts(mind);
|
|
2023
|
+
return {
|
|
2024
|
+
content: [{ type: "text", text: rendered }],
|
|
2025
|
+
details: { facts: factCount, mind: activeLabel() },
|
|
2026
|
+
};
|
|
2027
|
+
},
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
pi.registerTool({
|
|
2031
|
+
name: "memory_recall",
|
|
2032
|
+
label: "Recall Memory",
|
|
2033
|
+
description: [
|
|
2034
|
+
"Semantically search project memory for facts relevant to a query.",
|
|
2035
|
+
"Returns ranked results by relevance × confidence — much more targeted than memory_query.",
|
|
2036
|
+
"Facts returned enter working memory and get priority in context injection.",
|
|
2037
|
+
"Falls back to keyword search (FTS5) if embedding models are unavailable.",
|
|
2038
|
+
].join(" "),
|
|
2039
|
+
promptSnippet: "Semantic search over project memory — retrieve facts relevant to a query",
|
|
2040
|
+
promptGuidelines: [
|
|
2041
|
+
"Prefer memory_recall over memory_query for targeted retrieval — saves context tokens",
|
|
2042
|
+
"Recalled facts enter working memory and persist across compaction cycles",
|
|
2043
|
+
],
|
|
2044
|
+
parameters: Type.Object({
|
|
2045
|
+
query: Type.String({ description: "Natural language query describing what you're looking for" }),
|
|
2046
|
+
k: Type.Optional(Type.Number({ description: "Number of results to return (default: 10, max: 30)" })),
|
|
2047
|
+
section: Type.Optional(StringEnum(
|
|
2048
|
+
["Architecture", "Decisions", "Constraints", "Known Issues", "Patterns & Conventions", "Specs"] as const,
|
|
2049
|
+
{ description: "Optionally restrict search to a specific section" },
|
|
2050
|
+
)),
|
|
2051
|
+
}),
|
|
2052
|
+
renderCall(args: any, theme: any) {
|
|
2053
|
+
const q = args.query?.length > 50 ? args.query.slice(0, 47) + "…" : args.query;
|
|
2054
|
+
const sec = args.section ? ` in:${args.section}` : "";
|
|
2055
|
+
return sciCall("memory_recall", `"${q}"${sec}`, theme);
|
|
2056
|
+
},
|
|
2057
|
+
renderResult(result: any, { expanded }: any, theme: any) {
|
|
2058
|
+
if ((result as any).isError) return sciErr(result.content?.[0]?.text ?? "Error", theme);
|
|
2059
|
+
const d = result.details as { method?: string; results?: number; episodes?: number; workingMemorySize?: number } | undefined;
|
|
2060
|
+
const n = d?.results ?? 0;
|
|
2061
|
+
const method = d?.method ?? "search";
|
|
2062
|
+
const epis = d?.episodes ?? 0;
|
|
2063
|
+
const summary = n === 0
|
|
2064
|
+
? "no matches"
|
|
2065
|
+
: `${n} result${n !== 1 ? "s" : ""} via ${method}` + (epis > 0 ? ` + ${epis} episode${epis !== 1 ? "s" : ""}` : "");
|
|
2066
|
+
|
|
2067
|
+
if (expanded && n > 0) {
|
|
2068
|
+
const text = result.content?.[0]?.text ?? "";
|
|
2069
|
+
const lines = text.split("\n").filter(Boolean).slice(0, 12).map((line: string) => {
|
|
2070
|
+
// Highlight match percentages
|
|
2071
|
+
const m = line.match(/\((\d+)% match/);
|
|
2072
|
+
if (m) {
|
|
2073
|
+
const pct = parseInt(m[1]);
|
|
2074
|
+
const color = pct >= 70 ? "success" : pct >= 40 ? "warning" : "muted";
|
|
2075
|
+
return line.replace(`${m[1]}% match`, theme.fg(color as any, `${m[1]}%`));
|
|
2076
|
+
}
|
|
2077
|
+
return line;
|
|
2078
|
+
});
|
|
2079
|
+
return sciExpanded(lines, summary, theme);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
return n === 0 ? sciErr(summary, theme) : sciOk(summary, theme);
|
|
2083
|
+
},
|
|
2084
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, _ctx: any): Promise<any> {
|
|
2085
|
+
if (!store) {
|
|
2086
|
+
return { content: [{ type: "text", text: "Project memory not initialized." }], isError: true };
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
const mind = activeMind();
|
|
2090
|
+
const k = Math.min(params.k ?? 10, 30);
|
|
2091
|
+
|
|
2092
|
+
// Try semantic search first
|
|
2093
|
+
if (embeddingAvailable) {
|
|
2094
|
+
const queryVec = await embedText(params.query);
|
|
2095
|
+
if (queryVec) {
|
|
2096
|
+
const results = store.semanticSearch(queryVec, mind, {
|
|
2097
|
+
k,
|
|
2098
|
+
minSimilarity: 0.25,
|
|
2099
|
+
section: params.section,
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
if (results.length > 0) {
|
|
2103
|
+
// Add to working memory
|
|
2104
|
+
addToWorkingMemory(...results.map(r => r.id));
|
|
2105
|
+
|
|
2106
|
+
const lines = results.map((r, i) => {
|
|
2107
|
+
const sim = (r.similarity * 100).toFixed(0);
|
|
2108
|
+
const conf = (r.confidence * 100).toFixed(0);
|
|
2109
|
+
return `${i + 1}. [${r.id}] (${r.section}, ${sim}% match, ${conf}% conf) ${r.content}`;
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
// Also search episodes
|
|
2113
|
+
let episodeLines = "";
|
|
2114
|
+
const episodeVec = queryVec; // reuse
|
|
2115
|
+
const episodeHits = store.semanticSearchEpisodes(episodeVec, mind, { k: 3, minSimilarity: 0.35 });
|
|
2116
|
+
if (episodeHits.length > 0) {
|
|
2117
|
+
episodeLines = "\n\nRelated sessions:\n" + episodeHits.map(e =>
|
|
2118
|
+
`- ${e.date}: ${e.title} (${(e.similarity * 100).toFixed(0)}% match)\n ${e.narrative.slice(0, 200)}${e.narrative.length > 200 ? "…" : ""}`
|
|
2119
|
+
).join("\n");
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
return {
|
|
2123
|
+
content: [{ type: "text", text: lines.join("\n") + episodeLines }],
|
|
2124
|
+
details: {
|
|
2125
|
+
method: "semantic",
|
|
2126
|
+
results: results.length,
|
|
2127
|
+
workingMemorySize: workingMemory.size,
|
|
2128
|
+
episodes: episodeHits.length,
|
|
2129
|
+
},
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// Fallback: FTS5 keyword search on active facts
|
|
2136
|
+
const ftsResults = store.searchFacts(params.query, mind);
|
|
2137
|
+
const active = ftsResults.filter(f => f.status === "active");
|
|
2138
|
+
const limited = active.slice(0, k);
|
|
2139
|
+
|
|
2140
|
+
if (limited.length === 0) {
|
|
2141
|
+
return { content: [{ type: "text", text: `No matching facts for: "${params.query}"` }] };
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
addToWorkingMemory(...limited.map(r => r.id));
|
|
2145
|
+
|
|
2146
|
+
const lines = limited.map((r, i) =>
|
|
2147
|
+
`${i + 1}. [${r.id}] (${r.section}) ${r.content}`
|
|
2148
|
+
);
|
|
2149
|
+
|
|
2150
|
+
return {
|
|
2151
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
2152
|
+
details: { method: "fts5", results: limited.length, workingMemorySize: workingMemory.size },
|
|
2153
|
+
};
|
|
2154
|
+
},
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
pi.registerTool({
|
|
2158
|
+
name: "memory_episodes",
|
|
2159
|
+
label: "Session Episodes",
|
|
2160
|
+
description: [
|
|
2161
|
+
"Search session episode narratives — summaries of what happened in past work sessions.",
|
|
2162
|
+
"Episodes capture goals, decisions, sequences, and outcomes that individual facts don't preserve.",
|
|
2163
|
+
"Returns ranked results by semantic similarity to query.",
|
|
2164
|
+
].join(" "),
|
|
2165
|
+
promptSnippet: "Search past session narratives for episodic context (goals, decisions, outcomes)",
|
|
2166
|
+
parameters: Type.Object({
|
|
2167
|
+
query: Type.String({ description: "What you're looking for in past sessions" }),
|
|
2168
|
+
k: Type.Optional(Type.Number({ description: "Number of results (default: 5)" })),
|
|
2169
|
+
}),
|
|
2170
|
+
renderCall(args: any, theme: any) {
|
|
2171
|
+
const q = args.query?.length > 50 ? args.query.slice(0, 47) + "…" : args.query;
|
|
2172
|
+
return sciCall("memory_episodes", `"${q}"`, theme);
|
|
2173
|
+
},
|
|
2174
|
+
renderResult(result: any, { expanded }: any, theme: any) {
|
|
2175
|
+
if ((result as any).isError) return sciErr(result.content?.[0]?.text ?? "Error", theme);
|
|
2176
|
+
const d = result.details as { method?: string; results?: number } | undefined;
|
|
2177
|
+
const n = d?.results ?? 0;
|
|
2178
|
+
const method = d?.method ?? "recent";
|
|
2179
|
+
const summary = n === 0 ? "no episodes" : `${n} episode${n !== 1 ? "s" : ""} (${method})`;
|
|
2180
|
+
|
|
2181
|
+
if (expanded && n > 0) {
|
|
2182
|
+
const text = result.content?.[0]?.text ?? "";
|
|
2183
|
+
// Extract session titles (bold date: title lines)
|
|
2184
|
+
const lines = text.split("\n").filter((l: string) => l.match(/^\d+\.\s+\*\*/)).map((l: string) => {
|
|
2185
|
+
const m = l.match(/\*\*(.+?)\*\*/);
|
|
2186
|
+
return m ? theme.fg("accent", m[1]) : l;
|
|
2187
|
+
}).slice(0, 8);
|
|
2188
|
+
return sciExpanded(lines, summary, theme);
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
return n === 0 ? sciErr(summary, theme) : sciOk(summary, theme);
|
|
2192
|
+
},
|
|
2193
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, _ctx: any): Promise<any> {
|
|
2194
|
+
if (!store) {
|
|
2195
|
+
return { content: [{ type: "text", text: "Project memory not initialized." }], isError: true };
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
const mind = activeMind();
|
|
2199
|
+
const k = Math.min(params.k ?? 5, 15);
|
|
2200
|
+
|
|
2201
|
+
// Try semantic search
|
|
2202
|
+
if (embeddingAvailable) {
|
|
2203
|
+
const queryVec = await embedText(params.query);
|
|
2204
|
+
if (queryVec) {
|
|
2205
|
+
const results = store.semanticSearchEpisodes(queryVec, mind, { k, minSimilarity: 0.25 });
|
|
2206
|
+
if (results.length > 0) {
|
|
2207
|
+
const lines = results.map((e, i) => {
|
|
2208
|
+
const sim = (e.similarity * 100).toFixed(0);
|
|
2209
|
+
const factIds = store!.getEpisodeFactIds(e.id);
|
|
2210
|
+
return [
|
|
2211
|
+
`${i + 1}. **${e.date}: ${e.title}** (${sim}% match)`,
|
|
2212
|
+
` ${e.narrative}`,
|
|
2213
|
+
factIds.length > 0 ? ` Related facts: ${factIds.join(", ")}` : "",
|
|
2214
|
+
].filter(Boolean).join("\n");
|
|
2215
|
+
});
|
|
2216
|
+
return {
|
|
2217
|
+
content: [{ type: "text", text: lines.join("\n\n") }],
|
|
2218
|
+
details: { method: "semantic", results: results.length },
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// Fallback: return most recent episodes
|
|
2225
|
+
const recent = store.getEpisodes(mind, k);
|
|
2226
|
+
if (recent.length === 0) {
|
|
2227
|
+
return { content: [{ type: "text", text: "No session episodes recorded yet." }] };
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
const lines = recent.map((e, i) =>
|
|
2231
|
+
`${i + 1}. **${e.date}: ${e.title}**\n ${e.narrative}`
|
|
2232
|
+
);
|
|
2233
|
+
return {
|
|
2234
|
+
content: [{ type: "text", text: lines.join("\n\n") }],
|
|
2235
|
+
details: { method: "recent", results: recent.length },
|
|
2236
|
+
};
|
|
2237
|
+
},
|
|
2238
|
+
});
|
|
2239
|
+
|
|
2240
|
+
pi.registerTool({
|
|
2241
|
+
name: "memory_focus",
|
|
2242
|
+
label: "Focus Working Memory",
|
|
2243
|
+
description: [
|
|
2244
|
+
"Pin specific facts into working memory so they persist across compaction.",
|
|
2245
|
+
"Working memory facts get priority injection in context. Use to keep important facts available",
|
|
2246
|
+
"throughout a long session without re-retrieving them. Call memory_release to clear.",
|
|
2247
|
+
].join(" "),
|
|
2248
|
+
promptSnippet: "Pin facts to working memory (survives compaction, priority injection)",
|
|
2249
|
+
parameters: Type.Object({
|
|
2250
|
+
fact_ids: Type.Array(Type.String(), {
|
|
2251
|
+
description: "Fact IDs to pin (from memory_query or memory_recall output)",
|
|
2252
|
+
minItems: 1,
|
|
2253
|
+
}),
|
|
2254
|
+
}),
|
|
2255
|
+
renderCall(args: any, theme: any) {
|
|
2256
|
+
const n = Array.isArray(args.fact_ids) ? args.fact_ids.length : "?";
|
|
2257
|
+
return sciCall("memory_focus", `pin ${n} fact${n !== 1 ? "s" : ""}`, theme);
|
|
2258
|
+
},
|
|
2259
|
+
renderResult(result: any, _opts: any, theme: any) {
|
|
2260
|
+
const d = result.details as { workingMemorySize?: number } | undefined;
|
|
2261
|
+
const sz = d?.workingMemorySize ?? 0;
|
|
2262
|
+
return sciOk(`${sz} facts in working memory`, theme);
|
|
2263
|
+
},
|
|
2264
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, _ctx: any): Promise<any> {
|
|
2265
|
+
addToWorkingMemory(...params.fact_ids);
|
|
2266
|
+
return {
|
|
2267
|
+
content: [{
|
|
2268
|
+
type: "text",
|
|
2269
|
+
text: `Pinned ${params.fact_ids.length} facts to working memory (${workingMemory.size}/${WORKING_MEMORY_CAP} slots used).`,
|
|
2270
|
+
}],
|
|
2271
|
+
details: { workingMemorySize: workingMemory.size },
|
|
2272
|
+
};
|
|
2273
|
+
},
|
|
2274
|
+
});
|
|
2275
|
+
|
|
2276
|
+
pi.registerTool({
|
|
2277
|
+
name: "memory_release",
|
|
2278
|
+
label: "Release Working Memory",
|
|
2279
|
+
description: "Clear working memory buffer, releasing all pinned facts.",
|
|
2280
|
+
promptSnippet: "Clear working memory — release all pinned facts",
|
|
2281
|
+
parameters: Type.Object({}),
|
|
2282
|
+
renderCall(_args: any, theme: any) {
|
|
2283
|
+
return sciCall("memory_release", "clear", theme);
|
|
2284
|
+
},
|
|
2285
|
+
renderResult(result: any, _opts: any, theme: any) {
|
|
2286
|
+
const text = result.content?.[0]?.text ?? "cleared";
|
|
2287
|
+
return sciOk(text, theme);
|
|
2288
|
+
},
|
|
2289
|
+
async execute(_toolCallId: string, _params: any, _signal: any, _onUpdate: any, _ctx: any): Promise<any> {
|
|
2290
|
+
const released = workingMemory.size;
|
|
2291
|
+
workingMemory.clear();
|
|
2292
|
+
return {
|
|
2293
|
+
content: [{
|
|
2294
|
+
type: "text",
|
|
2295
|
+
text: `Released ${released} facts from working memory.`,
|
|
2296
|
+
}],
|
|
2297
|
+
};
|
|
2298
|
+
},
|
|
2299
|
+
});
|
|
2300
|
+
|
|
2301
|
+
pi.registerTool({
|
|
2302
|
+
name: "memory_store",
|
|
2303
|
+
label: "Store Memory",
|
|
2304
|
+
description: [
|
|
2305
|
+
"Explicitly add or update a fact in project memory.",
|
|
2306
|
+
"Use for important discoveries: architectural decisions, constraints,",
|
|
2307
|
+
"non-obvious patterns, tricky bugs, environment details.",
|
|
2308
|
+
"Facts persist across sessions.",
|
|
2309
|
+
].join(" "),
|
|
2310
|
+
promptSnippet: "Store a fact to project memory (persists across sessions)",
|
|
2311
|
+
promptGuidelines: [
|
|
2312
|
+
"Store conclusions, not investigation steps — if you're still debugging, don't store yet",
|
|
2313
|
+
"Store current state, not transitions — write 'X is used for Y', not 'X replaced Z for Y'",
|
|
2314
|
+
"Before storing, check if an existing fact covers it — use memory_supersede instead of adding duplicates",
|
|
2315
|
+
"After resolving a bug, archive all investigation breadcrumbs and store one decision fact about the fix",
|
|
2316
|
+
"Prefer pointer facts ('X does Y. See path/to/file.ts') over inlining implementation details",
|
|
2317
|
+
],
|
|
2318
|
+
parameters: Type.Object({
|
|
2319
|
+
section: StringEnum(
|
|
2320
|
+
["Architecture", "Decisions", "Constraints", "Known Issues", "Patterns & Conventions", "Specs"] as const,
|
|
2321
|
+
{ description: "Memory section to add the fact to" },
|
|
2322
|
+
),
|
|
2323
|
+
content: Type.String({
|
|
2324
|
+
description: "Fact to add (single bullet point, self-contained)",
|
|
2325
|
+
}),
|
|
2326
|
+
}),
|
|
2327
|
+
renderCall(args: any, theme: any) {
|
|
2328
|
+
const sec = args.section ?? "?";
|
|
2329
|
+
const preview = args.content?.length > 45 ? args.content.slice(0, 42) + "…" : args.content;
|
|
2330
|
+
return sciCall("memory_store", `${sec} · ${preview}`, theme);
|
|
2331
|
+
},
|
|
2332
|
+
renderResult(result: any, { expanded }: any, theme: any) {
|
|
2333
|
+
if ((result as any).isError) return sciErr(result.content?.[0]?.text ?? "Error", theme);
|
|
2334
|
+
const d = result.details as { section?: string; id?: string; reinforced?: boolean; facts?: number; conflicts?: boolean } | undefined;
|
|
2335
|
+
const sec = d?.section ?? "?";
|
|
2336
|
+
const reinforced = d?.reinforced ?? false;
|
|
2337
|
+
const conflicts = d?.conflicts ?? false;
|
|
2338
|
+
const icon = reinforced ? "↻" : conflicts ? "⚠" : "✓";
|
|
2339
|
+
const verb = reinforced ? "reinforced" : "stored";
|
|
2340
|
+
const summary = `${icon} ${verb} → ${sec}` + (d?.facts != null ? ` (${d.facts} total)` : "");
|
|
2341
|
+
|
|
2342
|
+
if (expanded && conflicts) {
|
|
2343
|
+
const text = result.content?.[0]?.text ?? "";
|
|
2344
|
+
const lines = text.split("\n").filter(Boolean);
|
|
2345
|
+
return sciExpanded(lines, summary, theme);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
return sciOk(summary, theme);
|
|
2349
|
+
},
|
|
2350
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, _ctx: any): Promise<any> {
|
|
2351
|
+
if (!store) {
|
|
2352
|
+
return {
|
|
2353
|
+
content: [{ type: "text", text: "Project memory not initialized." }],
|
|
2354
|
+
isError: true,
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
const mind = activeMind();
|
|
2359
|
+
const content = params.content.replace(/^-\s*/, "").trim();
|
|
2360
|
+
|
|
2361
|
+
// Pre-store conflict detection: embed and check BEFORE committing
|
|
2362
|
+
let conflictWarning = "";
|
|
2363
|
+
let precomputedVec: Float32Array | null = null;
|
|
2364
|
+
if (embeddingAvailable) {
|
|
2365
|
+
precomputedVec = await embedText(`[${params.section}] ${content}`);
|
|
2366
|
+
if (precomputedVec) {
|
|
2367
|
+
const similar = store.findSimilarFacts(content, precomputedVec, mind, params.section, {
|
|
2368
|
+
threshold: 0.85,
|
|
2369
|
+
limit: 3,
|
|
2370
|
+
});
|
|
2371
|
+
if (similar.length > 0) {
|
|
2372
|
+
const warnings = similar.map(s =>
|
|
2373
|
+
` ⚠ [${s.id}] (${(s.similarity * 100).toFixed(0)}% similar): ${s.content.slice(0, 100)}`
|
|
2374
|
+
);
|
|
2375
|
+
conflictWarning = "\n\nPotential conflicts detected — consider using memory_supersede if this replaces an existing fact:\n" + warnings.join("\n");
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
const result = store.storeFact({
|
|
2381
|
+
mind,
|
|
2382
|
+
section: params.section as any,
|
|
2383
|
+
content,
|
|
2384
|
+
source: "manual",
|
|
2385
|
+
});
|
|
2386
|
+
|
|
2387
|
+
if (result.duplicate) {
|
|
2388
|
+
addToWorkingMemory(result.id);
|
|
2389
|
+
return {
|
|
2390
|
+
content: [{ type: "text", text: `Reinforced existing fact in ${params.section}: ${content}` }],
|
|
2391
|
+
details: { section: params.section, reinforced: true, id: result.id },
|
|
2392
|
+
};
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// Store the precomputed vector directly (avoids redundant embedding call)
|
|
2396
|
+
if (precomputedVec && embeddingModel) {
|
|
2397
|
+
store.storeFactVector(result.id, precomputedVec, embeddingModel);
|
|
2398
|
+
} else {
|
|
2399
|
+
trackEmbed(embedFact(result.id).catch(() => {})); // tracked fire-and-forget
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
addToWorkingMemory(result.id);
|
|
2403
|
+
return {
|
|
2404
|
+
content: [{ type: "text", text: `Stored in ${params.section}: ${content}${conflictWarning}` }],
|
|
2405
|
+
details: { section: params.section, id: result.id, facts: store.countActiveFacts(mind), conflicts: conflictWarning ? true : false },
|
|
2406
|
+
};
|
|
2407
|
+
},
|
|
2408
|
+
});
|
|
2409
|
+
|
|
2410
|
+
pi.registerTool({
|
|
2411
|
+
name: "memory_supersede",
|
|
2412
|
+
label: "Supersede Memory Fact",
|
|
2413
|
+
description: [
|
|
2414
|
+
"Atomically replace an existing fact with a new version.",
|
|
2415
|
+
"The old fact is marked superseded (searchable in archive) and the new fact is stored.",
|
|
2416
|
+
"Ideal for updating specs, correcting facts, or evolving decisions.",
|
|
2417
|
+
"Get fact IDs from memory_query output (shown in [brackets]).",
|
|
2418
|
+
].join(" "),
|
|
2419
|
+
promptSnippet: "Replace an existing fact with an updated version (atomic supersede)",
|
|
2420
|
+
parameters: Type.Object({
|
|
2421
|
+
fact_id: Type.String({ description: "ID of the fact to supersede" }),
|
|
2422
|
+
section: StringEnum(
|
|
2423
|
+
["Architecture", "Decisions", "Constraints", "Known Issues", "Patterns & Conventions", "Specs"] as const,
|
|
2424
|
+
{ description: "Memory section for the new fact (can differ from original)" },
|
|
2425
|
+
),
|
|
2426
|
+
content: Type.String({
|
|
2427
|
+
description: "New fact content (replaces the old fact)",
|
|
2428
|
+
}),
|
|
2429
|
+
}),
|
|
2430
|
+
renderCall(args: any, theme: any) {
|
|
2431
|
+
return sciCall("memory_supersede", `[${args.fact_id}] → ${args.section}`, theme);
|
|
2432
|
+
},
|
|
2433
|
+
renderResult(result: any, _opts: any, theme: any) {
|
|
2434
|
+
if ((result as any).isError) return sciErr(result.content?.[0]?.text ?? "Error", theme);
|
|
2435
|
+
const d = result.details as { oldId?: string; newId?: string; section?: string; facts?: number } | undefined;
|
|
2436
|
+
return sciOk(`[${d?.oldId}] → [${d?.newId}] in ${d?.section}` + (d?.facts != null ? ` (${d.facts} total)` : ""), theme);
|
|
2437
|
+
},
|
|
2438
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, _ctx: any): Promise<any> {
|
|
2439
|
+
if (!store) {
|
|
2440
|
+
return {
|
|
2441
|
+
content: [{ type: "text", text: "Project memory not initialized." }],
|
|
2442
|
+
isError: true,
|
|
2443
|
+
};
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
const mind = activeMind();
|
|
2447
|
+
const content = params.content.replace(/^-\s*/, "").trim();
|
|
2448
|
+
const result = store.storeFact({
|
|
2449
|
+
mind,
|
|
2450
|
+
section: params.section as any,
|
|
2451
|
+
content,
|
|
2452
|
+
source: "manual",
|
|
2453
|
+
supersedes: params.fact_id,
|
|
2454
|
+
});
|
|
2455
|
+
|
|
2456
|
+
return {
|
|
2457
|
+
content: [{ type: "text", text: `Superseded [${params.fact_id}] → new fact in ${params.section}: ${content}` }],
|
|
2458
|
+
details: { section: params.section, oldId: params.fact_id, newId: result.id, facts: store.countActiveFacts(mind) },
|
|
2459
|
+
};
|
|
2460
|
+
},
|
|
2461
|
+
});
|
|
2462
|
+
|
|
2463
|
+
pi.registerTool({
|
|
2464
|
+
name: "memory_search_archive",
|
|
2465
|
+
label: "Search Memory Archive",
|
|
2466
|
+
description: [
|
|
2467
|
+
"Search archived project memories from previous months.",
|
|
2468
|
+
"Use when active memory doesn't have historical context you need —",
|
|
2469
|
+
"past decisions, old constraints, migration history, removed facts.",
|
|
2470
|
+
].join(" "),
|
|
2471
|
+
promptSnippet: "Search archived memories from previous months",
|
|
2472
|
+
parameters: Type.Object({
|
|
2473
|
+
query: Type.String({ description: "Search terms (file paths, symbol names, concepts)" }),
|
|
2474
|
+
}),
|
|
2475
|
+
renderCall(args: any, theme: any) {
|
|
2476
|
+
const q = args.query?.length > 50 ? args.query.slice(0, 47) + "…" : args.query;
|
|
2477
|
+
return sciCall("memory_search_archive", `"${q}"`, theme);
|
|
2478
|
+
},
|
|
2479
|
+
renderResult(result: any, { expanded }: any, theme: any) {
|
|
2480
|
+
const d = result.details as { totalMatches?: number; crossMind?: boolean } | undefined;
|
|
2481
|
+
const n = d?.totalMatches ?? 0;
|
|
2482
|
+
const cross = d?.crossMind ? " (cross-mind)" : "";
|
|
2483
|
+
const summary = n === 0 ? "no archived matches" : `${n} archived fact${n !== 1 ? "s" : ""}${cross}`;
|
|
2484
|
+
|
|
2485
|
+
if (expanded && n > 0) {
|
|
2486
|
+
const text = result.content?.[0]?.text ?? "";
|
|
2487
|
+
const lines = text.split("\n").filter(Boolean).slice(0, 10);
|
|
2488
|
+
return sciExpanded(lines, summary, theme);
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
return n === 0 ? sciErr(summary, theme) : sciOk(summary, theme);
|
|
2492
|
+
},
|
|
2493
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, _ctx: any): Promise<any> {
|
|
2494
|
+
if (!store) {
|
|
2495
|
+
return { content: [{ type: "text", text: "Project memory not initialized." }] };
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
const mind = activeMind();
|
|
2499
|
+
const results = store.searchArchive(params.query, mind);
|
|
2500
|
+
|
|
2501
|
+
if (results.length === 0) {
|
|
2502
|
+
// Also try cross-mind search
|
|
2503
|
+
const allResults = store.searchArchive(params.query);
|
|
2504
|
+
if (allResults.length === 0) {
|
|
2505
|
+
return { content: [{ type: "text", text: "No matches in memory archive." }] };
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
const formatted = allResults
|
|
2509
|
+
.map(f => `[${f.mind}/${f.section}] ${f.content} (${f.status}, ${f.created_at.split("T")[0]})`)
|
|
2510
|
+
.join("\n");
|
|
2511
|
+
|
|
2512
|
+
return {
|
|
2513
|
+
content: [{ type: "text", text: `Cross-mind archive results:\n${formatted}` }],
|
|
2514
|
+
details: { totalMatches: allResults.length, crossMind: true },
|
|
2515
|
+
};
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
const formatted = results
|
|
2519
|
+
.map(f => `[${f.section}] ${f.content} (${f.status}, ${f.created_at.split("T")[0]})`)
|
|
2520
|
+
.join("\n");
|
|
2521
|
+
|
|
2522
|
+
return {
|
|
2523
|
+
content: [{ type: "text", text: formatted }],
|
|
2524
|
+
details: { totalMatches: results.length },
|
|
2525
|
+
};
|
|
2526
|
+
},
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
pi.registerTool({
|
|
2530
|
+
name: "memory_connect",
|
|
2531
|
+
label: "Connect Facts",
|
|
2532
|
+
description: [
|
|
2533
|
+
"Create a directional relationship (edge) between two facts in the global knowledge base.",
|
|
2534
|
+
"Use when you identify meaningful connections between facts — dependencies, contradictions,",
|
|
2535
|
+
"generalizations, or causal relationships. Search for facts first to get their IDs.",
|
|
2536
|
+
"The relation is a short verb phrase describing the relationship from source to target.",
|
|
2537
|
+
"Common patterns: runs_on, depends_on, motivated_by, contradicts, enables, generalizes,",
|
|
2538
|
+
"instance_of, requires, conflicts_with, replaces, preceded_by.",
|
|
2539
|
+
].join(" "),
|
|
2540
|
+
promptSnippet: "Create a relationship between two facts in the knowledge graph",
|
|
2541
|
+
parameters: Type.Object({
|
|
2542
|
+
source_fact_id: Type.String({ description: "ID of the source fact" }),
|
|
2543
|
+
target_fact_id: Type.String({ description: "ID of the target fact" }),
|
|
2544
|
+
relation: Type.String({ description: "Short verb phrase: runs_on, depends_on, contradicts, etc." }),
|
|
2545
|
+
description: Type.String({ description: "Human-readable description of why these facts are connected" }),
|
|
2546
|
+
}),
|
|
2547
|
+
renderCall(args: any, theme: any) {
|
|
2548
|
+
return sciCall("memory_connect", `${args.source_fact_id} ─${args.relation}→ ${args.target_fact_id}`, theme);
|
|
2549
|
+
},
|
|
2550
|
+
renderResult(result: any, _opts: any, theme: any) {
|
|
2551
|
+
if ((result as any).isError) return sciErr(result.content?.[0]?.text ?? "Error", theme);
|
|
2552
|
+
const d = result.details as { reinforced?: boolean; relation?: string } | undefined;
|
|
2553
|
+
const verb = d?.reinforced ? "reinforced" : "connected";
|
|
2554
|
+
const rel = d?.relation ?? "→";
|
|
2555
|
+
return sciOk(`${verb} ─${rel}→`, theme);
|
|
2556
|
+
},
|
|
2557
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, _ctx: any): Promise<any> {
|
|
2558
|
+
// Resolve which store owns each fact — edges must connect facts in the same DB
|
|
2559
|
+
const sourceInProject = store?.getFact(params.source_fact_id);
|
|
2560
|
+
const sourceInGlobal = globalStore?.getFact(params.source_fact_id);
|
|
2561
|
+
const targetInProject = store?.getFact(params.target_fact_id);
|
|
2562
|
+
const targetInGlobal = globalStore?.getFact(params.target_fact_id);
|
|
2563
|
+
|
|
2564
|
+
const sourceFact = sourceInProject ?? sourceInGlobal;
|
|
2565
|
+
const targetFact = targetInProject ?? targetInGlobal;
|
|
2566
|
+
|
|
2567
|
+
if (!sourceFact) {
|
|
2568
|
+
return { content: [{ type: "text", text: `Source fact not found: ${params.source_fact_id}` }], isError: true };
|
|
2569
|
+
}
|
|
2570
|
+
if (!targetFact) {
|
|
2571
|
+
return { content: [{ type: "text", text: `Target fact not found: ${params.target_fact_id}` }], isError: true };
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
// Both facts must be in the same store for FK integrity
|
|
2575
|
+
const sourceIsGlobal = !!sourceInGlobal && !sourceInProject;
|
|
2576
|
+
const targetIsGlobal = !!targetInGlobal && !targetInProject;
|
|
2577
|
+
|
|
2578
|
+
let edgeStore: FactStore;
|
|
2579
|
+
if (sourceIsGlobal && targetIsGlobal) {
|
|
2580
|
+
edgeStore = globalStore!;
|
|
2581
|
+
} else if (!sourceIsGlobal && !targetIsGlobal) {
|
|
2582
|
+
edgeStore = store!;
|
|
2583
|
+
} else {
|
|
2584
|
+
return {
|
|
2585
|
+
content: [{ type: "text", text: "Cannot connect facts across databases. Both facts must be in the same store (project or global). Promote the project fact to global first." }],
|
|
2586
|
+
isError: true,
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
const result = edgeStore.storeEdge({
|
|
2591
|
+
sourceFact: params.source_fact_id,
|
|
2592
|
+
targetFact: params.target_fact_id,
|
|
2593
|
+
relation: params.relation,
|
|
2594
|
+
description: params.description,
|
|
2595
|
+
sourceMind: sourceFact.mind,
|
|
2596
|
+
targetMind: targetFact.mind,
|
|
2597
|
+
});
|
|
2598
|
+
|
|
2599
|
+
if (result.duplicate) {
|
|
2600
|
+
return {
|
|
2601
|
+
content: [{ type: "text", text: `Reinforced existing connection: ${sourceFact.content.slice(0, 50)} --${params.relation}--> ${targetFact.content.slice(0, 50)}` }],
|
|
2602
|
+
details: { id: result.id, reinforced: true },
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
return {
|
|
2607
|
+
content: [{ type: "text", text: `Connected: ${sourceFact.content.slice(0, 50)} --${params.relation}--> ${targetFact.content.slice(0, 50)}` }],
|
|
2608
|
+
details: { id: result.id, source: params.source_fact_id, target: params.target_fact_id, relation: params.relation },
|
|
2609
|
+
};
|
|
2610
|
+
},
|
|
2611
|
+
});
|
|
2612
|
+
|
|
2613
|
+
pi.registerTool({
|
|
2614
|
+
name: "memory_archive",
|
|
2615
|
+
label: "Archive Memory Fact",
|
|
2616
|
+
description: [
|
|
2617
|
+
"Archive one or more facts from project memory by ID.",
|
|
2618
|
+
"Use to remove stale, redundant, or incorrect facts.",
|
|
2619
|
+
"Archived facts are searchable via memory_search_archive but no longer injected into context.",
|
|
2620
|
+
"Get fact IDs from memory_query output (shown in [brackets] when using the tool).",
|
|
2621
|
+
].join(" "),
|
|
2622
|
+
promptSnippet: "Archive stale facts by ID (removes from active context, keeps in archive)",
|
|
2623
|
+
parameters: Type.Object({
|
|
2624
|
+
fact_ids: Type.Array(Type.String(), {
|
|
2625
|
+
description: "One or more fact IDs to archive",
|
|
2626
|
+
minItems: 1,
|
|
2627
|
+
}),
|
|
2628
|
+
reason: Type.Optional(Type.String({
|
|
2629
|
+
description: "Why these facts are being archived (logged, not shown to user)",
|
|
2630
|
+
})),
|
|
2631
|
+
}),
|
|
2632
|
+
renderCall(args: any, theme: any) {
|
|
2633
|
+
const n = Array.isArray(args.fact_ids) ? args.fact_ids.length : "?";
|
|
2634
|
+
return sciCall("memory_archive", `${n} fact${n !== 1 ? "s" : ""}`, theme);
|
|
2635
|
+
},
|
|
2636
|
+
renderResult(result: any, { expanded }: any, theme: any) {
|
|
2637
|
+
if ((result as any).isError) return sciErr(result.content?.[0]?.text ?? "Error", theme);
|
|
2638
|
+
const d = result.details as { archived?: number; remaining?: number } | undefined;
|
|
2639
|
+
const archived = d?.archived ?? 0;
|
|
2640
|
+
const remaining = d?.remaining;
|
|
2641
|
+
const summary = `${archived} archived` + (remaining != null ? ` · ${remaining} remaining` : "");
|
|
2642
|
+
|
|
2643
|
+
if (expanded) {
|
|
2644
|
+
const text = result.content?.[0]?.text ?? "";
|
|
2645
|
+
const lines = text.split("\n").filter(Boolean).slice(0, 10);
|
|
2646
|
+
return sciExpanded(lines, summary, theme);
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
return sciOk(summary, theme);
|
|
2650
|
+
},
|
|
2651
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, _ctx: any): Promise<any> {
|
|
2652
|
+
if (!store) {
|
|
2653
|
+
return { content: [{ type: "text", text: "Project memory not initialized." }], isError: true };
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
const mind = activeMind();
|
|
2657
|
+
const results: string[] = [];
|
|
2658
|
+
let archived = 0;
|
|
2659
|
+
|
|
2660
|
+
for (const id of params.fact_ids) {
|
|
2661
|
+
const fact = store.getFact(id);
|
|
2662
|
+
if (!fact) {
|
|
2663
|
+
results.push(`${id}: not found`);
|
|
2664
|
+
continue;
|
|
2665
|
+
}
|
|
2666
|
+
if (fact.status === "archived") {
|
|
2667
|
+
results.push(`${id}: already archived`);
|
|
2668
|
+
continue;
|
|
2669
|
+
}
|
|
2670
|
+
if (fact.mind !== mind) {
|
|
2671
|
+
results.push(`${id}: belongs to mind "${fact.mind}", not current mind "${mind}"`);
|
|
2672
|
+
continue;
|
|
2673
|
+
}
|
|
2674
|
+
store.archiveFact(id);
|
|
2675
|
+
archived++;
|
|
2676
|
+
results.push(`${id}: archived (was: ${fact.content.slice(0, 60)}…)`);
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
const remaining = store.countActiveFacts(mind);
|
|
2680
|
+
return {
|
|
2681
|
+
content: [{ type: "text", text: results.join("\n") }],
|
|
2682
|
+
details: { archived, remaining, reason: params.reason },
|
|
2683
|
+
};
|
|
2684
|
+
},
|
|
2685
|
+
});
|
|
2686
|
+
|
|
2687
|
+
pi.registerTool({
|
|
2688
|
+
name: "memory_compact",
|
|
2689
|
+
label: "Compact Context",
|
|
2690
|
+
description: [
|
|
2691
|
+
"Trigger context compaction to free up context window space.",
|
|
2692
|
+
"Summarizes older conversation history, preserving recent work.",
|
|
2693
|
+
"After compaction, use memory_query to reload project knowledge into the fresh context.",
|
|
2694
|
+
"Use proactively when context is growing large, or after bulk archiving stale facts.",
|
|
2695
|
+
"The compaction runs asynchronously — the agent loop continues after it completes.",
|
|
2696
|
+
].join(" "),
|
|
2697
|
+
promptSnippet: "Trigger context compaction to free context window space",
|
|
2698
|
+
promptGuidelines: [
|
|
2699
|
+
"Use proactively when context is growing large, or after bulk archiving stale facts",
|
|
2700
|
+
],
|
|
2701
|
+
parameters: Type.Object({
|
|
2702
|
+
instructions: Type.Optional(Type.String({
|
|
2703
|
+
description: "Optional focus instructions for the compaction summary (e.g., 'preserve the architecture discussion')",
|
|
2704
|
+
})),
|
|
2705
|
+
}),
|
|
2706
|
+
renderCall(_args: any, theme: any) {
|
|
2707
|
+
return sciCall("memory_compact", "trigger", theme);
|
|
2708
|
+
},
|
|
2709
|
+
renderResult(result: any, _opts: any, theme: any) {
|
|
2710
|
+
const d = result.details as { percent?: string; tokensBefore?: number } | undefined;
|
|
2711
|
+
const pct = d?.percent ?? "?";
|
|
2712
|
+
const tokens = d?.tokensBefore != null ? `${(d.tokensBefore / 1000).toFixed(0)}k tokens` : "";
|
|
2713
|
+
return sciOk(`compacting (was ${pct}${tokens ? `, ${tokens}` : ""})`, theme);
|
|
2714
|
+
},
|
|
2715
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, ctx: any): Promise<any> {
|
|
2716
|
+
const usage = ctx.getContextUsage();
|
|
2717
|
+
const pct = usage?.percent != null ? `${Math.round(usage.percent)}%` : "unknown";
|
|
2718
|
+
const tokens = usage?.tokens?.toLocaleString() ?? "unknown";
|
|
2719
|
+
|
|
2720
|
+
// Resume is handled centrally in session_compact handler (covers all compaction sources).
|
|
2721
|
+
ctx.compact({
|
|
2722
|
+
customInstructions: params.instructions,
|
|
2723
|
+
onError: (err: Error) => {
|
|
2724
|
+
compactionRetryCount++;
|
|
2725
|
+
console.error(`[project-memory] Manual compaction failed: ${err.message}`);
|
|
2726
|
+
|
|
2727
|
+
if (compactionRetryCount < config.compactionRetryLimit) {
|
|
2728
|
+
// Retry with local model
|
|
2729
|
+
useLocalCompaction = true;
|
|
2730
|
+
ctx.compact({
|
|
2731
|
+
customInstructions: params.instructions,
|
|
2732
|
+
onError: (retryErr: Error) => {
|
|
2733
|
+
console.error(`[project-memory] Local model compaction also failed: ${retryErr.message}`);
|
|
2734
|
+
if (ctx.hasUI) {
|
|
2735
|
+
ctx.ui.notify("Compaction failed (cloud + local).", "error");
|
|
2736
|
+
}
|
|
2737
|
+
},
|
|
2738
|
+
});
|
|
2739
|
+
} else if (ctx.hasUI) {
|
|
2740
|
+
ctx.ui.notify("Compaction failed after max retries.", "error");
|
|
2741
|
+
}
|
|
2742
|
+
},
|
|
2743
|
+
});
|
|
2744
|
+
|
|
2745
|
+
return {
|
|
2746
|
+
content: [{
|
|
2747
|
+
type: "text",
|
|
2748
|
+
text: [
|
|
2749
|
+
`Context compaction triggered (was ${pct} full, ${tokens} tokens).`,
|
|
2750
|
+
"Compaction runs in the background — older messages will be summarized.",
|
|
2751
|
+
"You will be prompted to continue after compaction completes.",
|
|
2752
|
+
].join("\n"),
|
|
2753
|
+
}],
|
|
2754
|
+
details: { tokensBefore: usage?.tokens, percent: pct },
|
|
2755
|
+
};
|
|
2756
|
+
},
|
|
2757
|
+
});
|
|
2758
|
+
|
|
2759
|
+
// --- Interactive Mind Manager ---
|
|
2760
|
+
|
|
2761
|
+
function buildMindItems(minds: (MindRecord & { factCount: number })[], activeName: string): SelectItem[] {
|
|
2762
|
+
const items: SelectItem[] = [];
|
|
2763
|
+
|
|
2764
|
+
for (const mind of minds) {
|
|
2765
|
+
const isActive = activeName === mind.name;
|
|
2766
|
+
const badges: string[] = [
|
|
2767
|
+
isActive ? "active target" : mind.status,
|
|
2768
|
+
`${mind.factCount} facts`,
|
|
2769
|
+
];
|
|
2770
|
+
if (mind.readonly) badges.push("read-only");
|
|
2771
|
+
if (mind.origin_type === "link") badges.push("linked");
|
|
2772
|
+
if (mind.description) badges.push(mind.description);
|
|
2773
|
+
if (mind.parent) badges.push(`(from: ${mind.parent})`);
|
|
2774
|
+
items.push({
|
|
2775
|
+
value: mind.name,
|
|
2776
|
+
label: `${isActive ? "▸ " : " "}${mind.name}`,
|
|
2777
|
+
description: badges.join(" • "),
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
items.push({ value: "__create__", label: " + Create new mind", description: "Start a fresh memory store" });
|
|
2782
|
+
items.push({ value: "__link__", label: " ⟷ Link external mind", description: "Import from a path (read-only)" });
|
|
2783
|
+
items.push({ value: "__edit__", label: " ✎ Edit current mind", description: "Open rendered view in editor" });
|
|
2784
|
+
items.push({ value: "__refresh__", label: " ↻ Refresh current mind", description: "Run extraction to prune and consolidate" });
|
|
2785
|
+
|
|
2786
|
+
return items;
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
function notifyMindSwitch(newLabel: string, factCount: number): void {
|
|
2790
|
+
pi.sendMessage({
|
|
2791
|
+
customType: "project-memory",
|
|
2792
|
+
content: [
|
|
2793
|
+
`Memory context switched to "${newLabel}" (${factCount} facts).`,
|
|
2794
|
+
"Your previous memory_query results are stale.",
|
|
2795
|
+
"Use **memory_query** to read the current memory if you need project context.",
|
|
2796
|
+
].join(" "),
|
|
2797
|
+
display: false,
|
|
2798
|
+
}, {
|
|
2799
|
+
deliverAs: "nextTurn",
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
async function showMindActions(ctx: ExtensionCommandContext, mindName: string): Promise<void> {
|
|
2804
|
+
if (!store) return;
|
|
2805
|
+
|
|
2806
|
+
const mind = store.getMind(mindName);
|
|
2807
|
+
if (!mind) {
|
|
2808
|
+
ctx.ui.notify(`Mind "${mindName}" not found`, "error");
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
const isReadonly = mind.readonly === 1;
|
|
2813
|
+
const isLinked = mind.origin_type === "link";
|
|
2814
|
+
|
|
2815
|
+
const actions: SelectItem[] = [
|
|
2816
|
+
{ value: "switch", label: "Switch to this mind", description: "Make it the active memory store" },
|
|
2817
|
+
];
|
|
2818
|
+
if (!isReadonly) {
|
|
2819
|
+
actions.push({ value: "edit", label: "Edit in editor", description: "Edit rendered memory as markdown" });
|
|
2820
|
+
}
|
|
2821
|
+
if (isLinked) {
|
|
2822
|
+
actions.push({ value: "sync", label: "Sync from source", description: `Pull latest from ${mind.origin_path}` });
|
|
2823
|
+
}
|
|
2824
|
+
actions.push({ value: "fork", label: "Fork", description: "Create a writable copy with a new name" });
|
|
2825
|
+
actions.push({ value: "ingest", label: "Ingest into another mind", description: "Merge facts into a target" });
|
|
2826
|
+
if (!isReadonly && mindName !== "default") {
|
|
2827
|
+
actions.push({ value: "status", label: "Change status", description: `Currently: ${mind.status}` });
|
|
2828
|
+
}
|
|
2829
|
+
if (mindName !== "default") {
|
|
2830
|
+
actions.push({ value: "delete", label: "Delete", description: isLinked ? "Remove link (source unaffected)" : "Remove this mind permanently" });
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
const action = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
2834
|
+
const container = new Container();
|
|
2835
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
2836
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(` Mind: ${mindName} `)), 1, 0));
|
|
2837
|
+
if (mind.description) {
|
|
2838
|
+
container.addChild(new Text(theme.fg("muted", ` ${mind.description}`), 1, 0));
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
const selectList = new SelectList(actions, Math.min(actions.length, 10), {
|
|
2842
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
2843
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
2844
|
+
description: (t) => theme.fg("muted", t),
|
|
2845
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
2846
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
2847
|
+
});
|
|
2848
|
+
selectList.onSelect = (item) => done(item.value);
|
|
2849
|
+
selectList.onCancel = () => done(null);
|
|
2850
|
+
container.addChild(selectList);
|
|
2851
|
+
container.addChild(new Text(theme.fg("dim", " ↑↓ navigate • enter select • esc back"), 1, 0));
|
|
2852
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
2853
|
+
|
|
2854
|
+
return {
|
|
2855
|
+
render: (w) => container.render(w),
|
|
2856
|
+
invalidate: () => container.invalidate(),
|
|
2857
|
+
handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },
|
|
2858
|
+
};
|
|
2859
|
+
});
|
|
2860
|
+
|
|
2861
|
+
if (!action) return;
|
|
2862
|
+
|
|
2863
|
+
switch (action) {
|
|
2864
|
+
case "switch": {
|
|
2865
|
+
store!.setActiveMind(mindName === "default" ? null : mindName);
|
|
2866
|
+
updateStatus(ctx);
|
|
2867
|
+
const count = store!.countActiveFacts(mindName);
|
|
2868
|
+
ctx.ui.notify(`Switched to mind: ${mindName}`, "info");
|
|
2869
|
+
notifyMindSwitch(mindName, count);
|
|
2870
|
+
break;
|
|
2871
|
+
}
|
|
2872
|
+
case "edit": {
|
|
2873
|
+
const rendered = store!.renderForInjection(mindName);
|
|
2874
|
+
const edited = await ctx.ui.editor(`Edit Mind: ${mindName}`, rendered);
|
|
2875
|
+
if (edited !== undefined && edited !== rendered) {
|
|
2876
|
+
// Parse edited markdown back into facts — this is lossy but useful
|
|
2877
|
+
// Archive all current facts and re-import from edited content
|
|
2878
|
+
ctx.ui.notify("Direct editing not yet supported for SQLite store. Use memory_store tool.", "warning");
|
|
2879
|
+
}
|
|
2880
|
+
break;
|
|
2881
|
+
}
|
|
2882
|
+
case "fork": {
|
|
2883
|
+
const rawName = await ctx.ui.input("New mind name:");
|
|
2884
|
+
if (!rawName?.trim()) return;
|
|
2885
|
+
const newName = sanitizeMindName(rawName);
|
|
2886
|
+
if (!newName) {
|
|
2887
|
+
ctx.ui.notify("Name must contain at least one alphanumeric character", "error");
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
if (newName !== rawName.trim()) {
|
|
2891
|
+
ctx.ui.notify(`Name sanitized to: ${newName}`, "info");
|
|
2892
|
+
}
|
|
2893
|
+
if (store!.mindExists(newName)) {
|
|
2894
|
+
ctx.ui.notify(`Mind "${newName}" already exists`, "error");
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
const desc = await ctx.ui.input("Description:", `Fork of ${mindName}`);
|
|
2898
|
+
store!.forkMind(mindName, newName, desc ?? `Fork of ${mindName}`);
|
|
2899
|
+
ctx.ui.notify(`Forked "${mindName}" → "${newName}"`, "info");
|
|
2900
|
+
break;
|
|
2901
|
+
}
|
|
2902
|
+
case "ingest": {
|
|
2903
|
+
const allMinds = store!.listMinds().filter(m => m.name !== mindName);
|
|
2904
|
+
if (allMinds.length === 0) {
|
|
2905
|
+
ctx.ui.notify("No targets to ingest into", "warning");
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
const targetIdx = await ctx.ui.select(
|
|
2909
|
+
"Ingest into:",
|
|
2910
|
+
allMinds.map(m => `${m.name} (${m.factCount} facts)`),
|
|
2911
|
+
);
|
|
2912
|
+
if (targetIdx === undefined) return;
|
|
2913
|
+
const targetIndex = allMinds.findIndex(m => `${m.name} (${m.factCount} facts)` === targetIdx);
|
|
2914
|
+
if (targetIndex < 0) return;
|
|
2915
|
+
const target = allMinds[targetIndex];
|
|
2916
|
+
|
|
2917
|
+
const sourceCount = store!.countActiveFacts(mindName);
|
|
2918
|
+
const sourceReadonly = store!.isMindReadonly(mindName);
|
|
2919
|
+
const retireMsg = sourceReadonly ? "" : ` and retire "${mindName}"`;
|
|
2920
|
+
const ok = await ctx.ui.confirm(
|
|
2921
|
+
"Ingest Mind",
|
|
2922
|
+
`Merge ${sourceCount} facts from "${mindName}" into "${target.name}" (duplicates skipped)${retireMsg}?`,
|
|
2923
|
+
);
|
|
2924
|
+
if (!ok) return;
|
|
2925
|
+
|
|
2926
|
+
const result = store!.ingestMind(mindName, target.name);
|
|
2927
|
+
ctx.ui.notify(
|
|
2928
|
+
`Ingested ${result.factsIngested} facts into "${target.name}" (${result.duplicatesSkipped} duplicates skipped)`,
|
|
2929
|
+
"info",
|
|
2930
|
+
);
|
|
2931
|
+
|
|
2932
|
+
if (activeMind() === mindName) {
|
|
2933
|
+
store!.setActiveMind(target.name === "default" ? null : target.name);
|
|
2934
|
+
updateStatus(ctx);
|
|
2935
|
+
}
|
|
2936
|
+
break;
|
|
2937
|
+
}
|
|
2938
|
+
case "status": {
|
|
2939
|
+
const statuses = ["active", "refined", "retired"] as const;
|
|
2940
|
+
const idx = await ctx.ui.select("New status:", [...statuses]);
|
|
2941
|
+
if (idx === undefined) return;
|
|
2942
|
+
const statusIdx = statuses.indexOf(idx as typeof statuses[number]);
|
|
2943
|
+
if (statusIdx < 0) return;
|
|
2944
|
+
store!.setMindStatus(mindName, statuses[statusIdx]);
|
|
2945
|
+
ctx.ui.notify(`Status of "${mindName}" → ${statuses[statusIdx]}`, "info");
|
|
2946
|
+
break;
|
|
2947
|
+
}
|
|
2948
|
+
case "delete": {
|
|
2949
|
+
const ok = await ctx.ui.confirm("Delete Mind", `Permanently delete mind "${mindName}" and all its facts?`);
|
|
2950
|
+
if (!ok) return;
|
|
2951
|
+
const wasActive = activeMind() === mindName;
|
|
2952
|
+
store!.deleteMind(mindName);
|
|
2953
|
+
if (wasActive) {
|
|
2954
|
+
store!.setActiveMind(null);
|
|
2955
|
+
updateStatus(ctx);
|
|
2956
|
+
}
|
|
2957
|
+
ctx.ui.notify(`Deleted mind: ${mindName}`, "info");
|
|
2958
|
+
break;
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
// Expose lifecycle ingestion API to other extensions
|
|
2964
|
+
(pi as any).memory = {
|
|
2965
|
+
ingestLifecycle,
|
|
2966
|
+
ingestLifecycleBatch,
|
|
2967
|
+
};
|
|
2968
|
+
|
|
2969
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
2970
|
+
if (!ctx.hasUI || !store) return;
|
|
2971
|
+
|
|
2972
|
+
const theme = ctx.ui.theme;
|
|
2973
|
+
const mind = activeMind();
|
|
2974
|
+
const count = store.countActiveFacts(mind);
|
|
2975
|
+
|
|
2976
|
+
// Label + fact count as a single unit: "Memory: 2 facts" or "Memory(mind): 2 facts"
|
|
2977
|
+
const label = mind !== "default" ? `Memory(${mind}): ${count} facts` : `Memory: ${count} facts`;
|
|
2978
|
+
const badges: string[] = [];
|
|
2979
|
+
|
|
2980
|
+
// Working memory — pinned facts count
|
|
2981
|
+
if (workingMemory.size > 0) {
|
|
2982
|
+
badges.push(`${workingMemory.size} pinned`);
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
// Semantic search availability
|
|
2986
|
+
if (embeddingAvailable) {
|
|
2987
|
+
badges.push("semantic");
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
const status = badges.length > 0
|
|
2991
|
+
? `${label} · ${badges.join(" · ")}`
|
|
2992
|
+
: label;
|
|
2993
|
+
|
|
2994
|
+
ctx.ui.setStatus("memory", theme.fg("dim", status));
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
// --- Lifecycle Testing Tool ---
|
|
2998
|
+
|
|
2999
|
+
pi.registerTool({
|
|
3000
|
+
name: "memory_ingest_lifecycle",
|
|
3001
|
+
label: "Ingest Lifecycle Candidate",
|
|
3002
|
+
description: [
|
|
3003
|
+
"Internal tool for testing lifecycle candidate ingestion.",
|
|
3004
|
+
"Used by design-tree, openspec, and cleave extensions to emit structured lifecycle facts.",
|
|
3005
|
+
"Not intended for direct agent use - use memory_store for manual fact creation.",
|
|
3006
|
+
].join(" "),
|
|
3007
|
+
promptSnippet: "Test lifecycle candidate ingestion (internal tool)",
|
|
3008
|
+
parameters: Type.Object({
|
|
3009
|
+
source_kind: Type.Union([
|
|
3010
|
+
Type.Literal("design-decision"),
|
|
3011
|
+
Type.Literal("design-constraint"),
|
|
3012
|
+
Type.Literal("openspec-archive"),
|
|
3013
|
+
Type.Literal("openspec-assess"),
|
|
3014
|
+
Type.Literal("cleave-outcome"),
|
|
3015
|
+
Type.Literal("cleave-bug-fix"),
|
|
3016
|
+
], {
|
|
3017
|
+
description: "Source kind that generated this candidate",
|
|
3018
|
+
}),
|
|
3019
|
+
authority: Type.Union([
|
|
3020
|
+
Type.Literal("explicit"),
|
|
3021
|
+
Type.Literal("inferred"),
|
|
3022
|
+
], {
|
|
3023
|
+
description: "Authority level - explicit auto-stores, inferred needs confirmation",
|
|
3024
|
+
}),
|
|
3025
|
+
section: StringEnum(
|
|
3026
|
+
["Architecture", "Decisions", "Constraints", "Known Issues", "Patterns & Conventions", "Specs"] as const,
|
|
3027
|
+
{ description: "Target memory section" },
|
|
3028
|
+
),
|
|
3029
|
+
content: Type.String({
|
|
3030
|
+
description: "Fact content",
|
|
3031
|
+
}),
|
|
3032
|
+
artifact_ref_type: Type.Optional(Type.Union([
|
|
3033
|
+
Type.Literal("design-node"),
|
|
3034
|
+
Type.Literal("openspec-spec"),
|
|
3035
|
+
Type.Literal("openspec-baseline"),
|
|
3036
|
+
Type.Literal("cleave-review"),
|
|
3037
|
+
], {
|
|
3038
|
+
description: "Type of artifact",
|
|
3039
|
+
})),
|
|
3040
|
+
artifact_ref_path: Type.Optional(Type.String({
|
|
3041
|
+
description: "Path or identifier",
|
|
3042
|
+
})),
|
|
3043
|
+
artifact_ref_sub: Type.Optional(Type.String({
|
|
3044
|
+
description: "Optional sub-reference (e.g. decision title, spec section)",
|
|
3045
|
+
})),
|
|
3046
|
+
supersedes: Type.Optional(Type.String({
|
|
3047
|
+
description: "Optional fact ID to supersede",
|
|
3048
|
+
})),
|
|
3049
|
+
}),
|
|
3050
|
+
renderCall(args: any, theme: any) {
|
|
3051
|
+
return sciCall("memory_ingest_lifecycle", `${args.source_kind} (${args.authority})`, theme);
|
|
3052
|
+
},
|
|
3053
|
+
renderResult(result: any, _opts: any, theme: any) {
|
|
3054
|
+
const d = result.details as { autoStored?: boolean; duplicate?: boolean; factId?: string; reason?: string } | undefined;
|
|
3055
|
+
if (d?.autoStored) {
|
|
3056
|
+
return sciOk(d.duplicate ? `↻ reinforced [${d.factId}]` : `✓ stored [${d.factId}]`, theme);
|
|
3057
|
+
}
|
|
3058
|
+
return sciErr(d?.reason ?? "not stored", theme);
|
|
3059
|
+
},
|
|
3060
|
+
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, _ctx: any): Promise<any> {
|
|
3061
|
+
const candidate: LifecycleCandidate = {
|
|
3062
|
+
sourceKind: params.source_kind,
|
|
3063
|
+
authority: params.authority,
|
|
3064
|
+
section: params.section,
|
|
3065
|
+
content: params.content,
|
|
3066
|
+
supersedes: params.supersedes,
|
|
3067
|
+
};
|
|
3068
|
+
|
|
3069
|
+
if (params.artifact_ref_type && params.artifact_ref_path) {
|
|
3070
|
+
candidate.artifactRef = {
|
|
3071
|
+
type: params.artifact_ref_type,
|
|
3072
|
+
path: params.artifact_ref_path,
|
|
3073
|
+
subRef: params.artifact_ref_sub,
|
|
3074
|
+
};
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
const result = ingestLifecycle(candidate);
|
|
3078
|
+
|
|
3079
|
+
let responseText = "";
|
|
3080
|
+
if (result.autoStored) {
|
|
3081
|
+
if (result.duplicate) {
|
|
3082
|
+
responseText = `✓ Reinforced existing fact [${result.factId}]: ${candidate.content}`;
|
|
3083
|
+
} else {
|
|
3084
|
+
responseText = `✓ Stored new lifecycle fact [${result.factId}]: ${candidate.content}`;
|
|
3085
|
+
}
|
|
3086
|
+
} else {
|
|
3087
|
+
responseText = `⚠ Candidate not stored: ${result.reason}`;
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
return {
|
|
3091
|
+
content: [{ type: "text", text: responseText }],
|
|
3092
|
+
details: {
|
|
3093
|
+
sourceKind: candidate.sourceKind,
|
|
3094
|
+
authority: candidate.authority,
|
|
3095
|
+
autoStored: result.autoStored,
|
|
3096
|
+
duplicate: result.duplicate,
|
|
3097
|
+
factId: result.factId,
|
|
3098
|
+
reason: result.reason,
|
|
3099
|
+
},
|
|
3100
|
+
};
|
|
3101
|
+
},
|
|
3102
|
+
});
|
|
3103
|
+
|
|
3104
|
+
// --- Commands ---
|
|
3105
|
+
|
|
3106
|
+
pi.registerCommand("memory", {
|
|
3107
|
+
description: "Interactive mind manager — view, switch, create, fork, ingest memory stores",
|
|
3108
|
+
getArgumentCompletions: (prefix: string) => {
|
|
3109
|
+
const subs = ["edit", "refresh", "clear", "link", "stats"];
|
|
3110
|
+
const filtered = subs.filter((s) => s.startsWith(prefix));
|
|
3111
|
+
return filtered.length > 0 ? filtered.map((s) => ({ value: s, label: s })) : null;
|
|
3112
|
+
},
|
|
3113
|
+
handler: async (args, ctx) => {
|
|
3114
|
+
if (!store) {
|
|
3115
|
+
ctx.ui.notify("Project memory not initialized", "error");
|
|
3116
|
+
return;
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
const subcommand = args?.trim().split(/\s+/)[0] ?? "";
|
|
3120
|
+
|
|
3121
|
+
switch (subcommand) {
|
|
3122
|
+
case "edit": {
|
|
3123
|
+
const mind = activeMind();
|
|
3124
|
+
const rendered = store.renderForInjection(mind);
|
|
3125
|
+
const edited = await ctx.ui.editor("Project Memory:", rendered);
|
|
3126
|
+
if (edited !== undefined && edited !== rendered) {
|
|
3127
|
+
ctx.ui.notify("Direct editing not yet supported for SQLite store. Use memory_store tool.", "warning");
|
|
3128
|
+
} else {
|
|
3129
|
+
ctx.ui.notify("No changes", "info");
|
|
3130
|
+
}
|
|
3131
|
+
return;
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
case "refresh": {
|
|
3135
|
+
startRefresh(ctx);
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
case "clear": {
|
|
3140
|
+
const mind = activeMind();
|
|
3141
|
+
const count = store.countActiveFacts(mind);
|
|
3142
|
+
const ok = await ctx.ui.confirm("Clear Memory", `Archive all ${count} active facts in "${mind}"?`);
|
|
3143
|
+
if (ok) {
|
|
3144
|
+
const facts = store.getActiveFacts(mind);
|
|
3145
|
+
for (const f of facts) {
|
|
3146
|
+
store.archiveFact(f.id);
|
|
3147
|
+
}
|
|
3148
|
+
ctx.ui.notify(`Archived ${count} facts`, "info");
|
|
3149
|
+
updateStatus(ctx);
|
|
3150
|
+
}
|
|
3151
|
+
return;
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
case "link": {
|
|
3155
|
+
const parts = args?.trim().split(/\s+/).slice(1) ?? [];
|
|
3156
|
+
if (parts.length < 1) {
|
|
3157
|
+
ctx.ui.notify("Usage: /memory link <path> [name]", "warning");
|
|
3158
|
+
return;
|
|
3159
|
+
}
|
|
3160
|
+
const linkPath = parts[0];
|
|
3161
|
+
const rawName = parts[1] ?? path.basename(linkPath);
|
|
3162
|
+
const linkName = sanitizeMindName(rawName);
|
|
3163
|
+
if (!linkName) {
|
|
3164
|
+
ctx.ui.notify("Could not derive a valid name from path", "error");
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
if (store.mindExists(linkName)) {
|
|
3168
|
+
ctx.ui.notify(`Mind "${linkName}" already exists`, "error");
|
|
3169
|
+
return;
|
|
3170
|
+
}
|
|
3171
|
+
// For linked minds, we'd need to import from external path
|
|
3172
|
+
// This is a simplified version — full link/sync needs more work
|
|
3173
|
+
ctx.ui.notify(`Linked mind support is being rebuilt for SQLite store`, "warning");
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
case "stats": {
|
|
3178
|
+
const mind = activeMind();
|
|
3179
|
+
const facts = store.getActiveFacts(mind);
|
|
3180
|
+
const total = facts.length;
|
|
3181
|
+
const bySection = new Map<string, number>();
|
|
3182
|
+
const bySource = new Map<string, number>();
|
|
3183
|
+
let totalReinforcements = 0;
|
|
3184
|
+
let avgConfidence = 0;
|
|
3185
|
+
|
|
3186
|
+
for (const f of facts) {
|
|
3187
|
+
bySection.set(f.section, (bySection.get(f.section) ?? 0) + 1);
|
|
3188
|
+
bySource.set(f.source, (bySource.get(f.source) ?? 0) + 1);
|
|
3189
|
+
totalReinforcements += f.reinforcement_count;
|
|
3190
|
+
avgConfidence += f.confidence;
|
|
3191
|
+
}
|
|
3192
|
+
avgConfidence = total > 0 ? avgConfidence / total : 0;
|
|
3193
|
+
|
|
3194
|
+
const vectorCount = store.countFactVectors(mind);
|
|
3195
|
+
const episodeCount = store.countEpisodes(mind);
|
|
3196
|
+
|
|
3197
|
+
const lines = [
|
|
3198
|
+
`Mind: ${activeLabel()}`,
|
|
3199
|
+
`Active facts: ${total}`,
|
|
3200
|
+
`Embedded facts: ${vectorCount}/${total} (${total > 0 ? ((vectorCount / total) * 100).toFixed(0) : 0}%)`,
|
|
3201
|
+
`Episodes: ${episodeCount}`,
|
|
3202
|
+
`Working memory: ${workingMemory.size}/${WORKING_MEMORY_CAP}`,
|
|
3203
|
+
`Embedding model: ${embeddingAvailable ? embeddingModel : "unavailable"}`,
|
|
3204
|
+
`Avg confidence: ${(avgConfidence * 100).toFixed(1)}%`,
|
|
3205
|
+
`Avg reinforcements: ${(totalReinforcements / Math.max(total, 1)).toFixed(1)}`,
|
|
3206
|
+
"",
|
|
3207
|
+
...formatMemoryInjectionMetrics(sharedState.lastMemoryInjection),
|
|
3208
|
+
"",
|
|
3209
|
+
"By section:",
|
|
3210
|
+
...SECTIONS.map(s => ` ${s}: ${bySection.get(s) ?? 0}`),
|
|
3211
|
+
"",
|
|
3212
|
+
"By source:",
|
|
3213
|
+
...Array.from(bySource.entries()).map(([s, n]) => ` ${s}: ${n}`),
|
|
3214
|
+
];
|
|
3215
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
// Interactive mind manager
|
|
3221
|
+
const minds = store.listMinds();
|
|
3222
|
+
const active = activeMind();
|
|
3223
|
+
const items = buildMindItems(minds, active);
|
|
3224
|
+
|
|
3225
|
+
const selected = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
3226
|
+
const container = new Container();
|
|
3227
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
3228
|
+
container.addChild(new Text(
|
|
3229
|
+
theme.fg("accent", theme.bold(" Memory Minds ")) +
|
|
3230
|
+
theme.fg("dim", `(active: ${activeLabel()})`),
|
|
3231
|
+
1, 0,
|
|
3232
|
+
));
|
|
3233
|
+
|
|
3234
|
+
const selectList = new SelectList(items, Math.min(items.length + 1, 15), {
|
|
3235
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
3236
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
3237
|
+
description: (t) => theme.fg("muted", t),
|
|
3238
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
3239
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
3240
|
+
});
|
|
3241
|
+
selectList.onSelect = (item) => done(item.value);
|
|
3242
|
+
selectList.onCancel = () => done(null);
|
|
3243
|
+
container.addChild(selectList);
|
|
3244
|
+
container.addChild(new Text(theme.fg("dim", " ↑↓ navigate • enter select/switch • esc close"), 1, 0));
|
|
3245
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
3246
|
+
|
|
3247
|
+
return {
|
|
3248
|
+
render: (w) => container.render(w),
|
|
3249
|
+
invalidate: () => container.invalidate(),
|
|
3250
|
+
handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },
|
|
3251
|
+
};
|
|
3252
|
+
});
|
|
3253
|
+
|
|
3254
|
+
if (!selected) return;
|
|
3255
|
+
|
|
3256
|
+
if (selected === "__create__") {
|
|
3257
|
+
const rawName = await ctx.ui.input("Mind name:");
|
|
3258
|
+
if (!rawName?.trim()) return;
|
|
3259
|
+
const name = sanitizeMindName(rawName);
|
|
3260
|
+
if (!name) {
|
|
3261
|
+
ctx.ui.notify("Name must contain at least one alphanumeric character", "error");
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
if (name !== rawName.trim()) {
|
|
3265
|
+
ctx.ui.notify(`Name sanitized to: ${name}`, "info");
|
|
3266
|
+
}
|
|
3267
|
+
if (store.mindExists(name)) {
|
|
3268
|
+
ctx.ui.notify(`Mind "${name}" already exists`, "error");
|
|
3269
|
+
return;
|
|
3270
|
+
}
|
|
3271
|
+
const desc = await ctx.ui.input("Description:");
|
|
3272
|
+
store.createMind(name, desc ?? "");
|
|
3273
|
+
const activate = await ctx.ui.confirm("Activate", `Switch to "${name}" now?`);
|
|
3274
|
+
if (activate) {
|
|
3275
|
+
store.setActiveMind(name);
|
|
3276
|
+
updateStatus(ctx);
|
|
3277
|
+
notifyMindSwitch(name, 0);
|
|
3278
|
+
}
|
|
3279
|
+
ctx.ui.notify(`Created mind: ${name}`, "info");
|
|
3280
|
+
return;
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
if (selected === "__link__") {
|
|
3284
|
+
ctx.ui.notify("Linked mind support is being rebuilt for SQLite store", "warning");
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
if (selected === "__edit__") {
|
|
3289
|
+
const mind = activeMind();
|
|
3290
|
+
const rendered = store.renderForInjection(mind);
|
|
3291
|
+
const edited = await ctx.ui.editor("Edit Current Mind:", rendered);
|
|
3292
|
+
if (edited !== undefined && edited !== rendered) {
|
|
3293
|
+
ctx.ui.notify("Direct editing not yet supported for SQLite store. Use memory_store tool.", "warning");
|
|
3294
|
+
}
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
if (selected === "__refresh__") {
|
|
3299
|
+
startRefresh(ctx);
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
// Selected an existing mind — show actions
|
|
3304
|
+
await showMindActions(ctx, selected);
|
|
3305
|
+
},
|
|
3306
|
+
});
|
|
3307
|
+
|
|
3308
|
+
pi.registerCommand("exit", {
|
|
3309
|
+
description: "Run memory extraction and exit gracefully (avoids /reload terminal corruption)",
|
|
3310
|
+
handler: async (_args, ctx) => {
|
|
3311
|
+
if (!store) {
|
|
3312
|
+
ctx.shutdown();
|
|
3313
|
+
await new Promise<void>(resolve => {
|
|
3314
|
+
setTimeout(() => { resolve(); process.exit(0); }, 10_000);
|
|
3315
|
+
});
|
|
3316
|
+
return;
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
const mind = activeMind();
|
|
3320
|
+
const factsBefore = store.countActiveFacts(mind);
|
|
3321
|
+
|
|
3322
|
+
// Run a final extraction if we have conversation context
|
|
3323
|
+
if (!triggerState.isRunning) {
|
|
3324
|
+
ctx.ui.notify("Running final memory extraction before exit…", "info");
|
|
3325
|
+
triggerState.isRunning = true;
|
|
3326
|
+
try {
|
|
3327
|
+
await runExtractionCycle(ctx, config);
|
|
3328
|
+
} catch {
|
|
3329
|
+
// Best effort — don't block exit
|
|
3330
|
+
} finally {
|
|
3331
|
+
triggerState.isRunning = false;
|
|
3332
|
+
}
|
|
3333
|
+
} else {
|
|
3334
|
+
// Wait for in-flight extraction to fully settle
|
|
3335
|
+
if (activeExtractionPromise) {
|
|
3336
|
+
ctx.ui.notify("Waiting for in-flight extraction…", "info");
|
|
3337
|
+
try { await activeExtractionPromise; } catch { /* killed or failed */ }
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
const factsAfter = store.countActiveFacts(mind);
|
|
3342
|
+
const delta = factsAfter - factsBefore;
|
|
3343
|
+
|
|
3344
|
+
// Generate session episode BEFORE goodbye (user sees progress, not post-goodbye lag)
|
|
3345
|
+
const branch = ctx.sessionManager.getBranch();
|
|
3346
|
+
const messages = branch
|
|
3347
|
+
.filter((e): e is SessionMessageEntry => e.type === "message")
|
|
3348
|
+
.map((e) => e.message);
|
|
3349
|
+
|
|
3350
|
+
if (messages.length > 5) {
|
|
3351
|
+
ctx.ui.notify("Generating session summary…", "info");
|
|
3352
|
+
try {
|
|
3353
|
+
const recentMessages = messages.slice(-20);
|
|
3354
|
+
const serialized = serializeConversation(convertToLlm(recentMessages));
|
|
3355
|
+
const today = new Date().toISOString().split("T")[0];
|
|
3356
|
+
|
|
3357
|
+
const telemetry: SessionTelemetry = {
|
|
3358
|
+
date: today,
|
|
3359
|
+
toolCallCount: triggerState.toolCallsSinceExtract,
|
|
3360
|
+
filesWritten: [...sessionFilesWritten],
|
|
3361
|
+
filesEdited: [...sessionFilesEdited],
|
|
3362
|
+
};
|
|
3363
|
+
|
|
3364
|
+
// Fallback chain: Ollama → codex-spark → haiku → template (always succeeds)
|
|
3365
|
+
const episodeOutput = await generateEpisodeWithFallback(serialized, telemetry, config, ctx.cwd);
|
|
3366
|
+
|
|
3367
|
+
if (store) {
|
|
3368
|
+
const sessionFactIds = [...workingMemory];
|
|
3369
|
+
const episodeId = store.storeEpisode({
|
|
3370
|
+
mind,
|
|
3371
|
+
title: episodeOutput.title,
|
|
3372
|
+
narrative: episodeOutput.narrative,
|
|
3373
|
+
date: today,
|
|
3374
|
+
factIds: sessionFactIds.filter(id => store!.getFact(id)?.status === "active"),
|
|
3375
|
+
});
|
|
3376
|
+
|
|
3377
|
+
// Embed episode vector (tracked — shutdown awaits before DB close)
|
|
3378
|
+
if (embeddingAvailable) {
|
|
3379
|
+
trackEmbed(
|
|
3380
|
+
embedText(`${episodeOutput.title} ${episodeOutput.narrative}`)
|
|
3381
|
+
.then(vec => { if (vec && store) store.storeEpisodeVector(episodeId, vec, embeddingModel!); })
|
|
3382
|
+
.catch(() => {}),
|
|
3383
|
+
);
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
exitEpisodeDone = true;
|
|
3387
|
+
} catch {
|
|
3388
|
+
// Best effort — don't block exit
|
|
3389
|
+
}
|
|
3390
|
+
} else {
|
|
3391
|
+
exitEpisodeDone = true;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
// Build session-end summary from shared state
|
|
3395
|
+
const summaryLines: string[] = [];
|
|
3396
|
+
|
|
3397
|
+
// Git state
|
|
3398
|
+
try {
|
|
3399
|
+
const branchResult = await pi.exec("git", ["branch", "--show-current"], { timeout: 3_000, cwd: ctx.cwd });
|
|
3400
|
+
const statusResult = await pi.exec("git", ["status", "--short"], { timeout: 3_000, cwd: ctx.cwd });
|
|
3401
|
+
const branchName = branchResult.stdout.trim();
|
|
3402
|
+
const dirtyCount = statusResult.stdout.trim().split("\n").filter(Boolean).length;
|
|
3403
|
+
summaryLines.push(`🔀 ${branchName}${dirtyCount > 0 ? ` · ${dirtyCount} dirty` : " · clean"}`);
|
|
3404
|
+
} catch { /* ignore */ }
|
|
3405
|
+
|
|
3406
|
+
// Design tree
|
|
3407
|
+
const dt = sharedState.designTree;
|
|
3408
|
+
if (dt && dt.nodeCount > 0) {
|
|
3409
|
+
summaryLines.push(`🌳 Design: ${dt.nodeCount} nodes (${dt.decidedCount} decided, ${dt.exploringCount} exploring)`);
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
// OpenSpec
|
|
3413
|
+
const os = sharedState.openspec;
|
|
3414
|
+
if (os && os.changes.length > 0) {
|
|
3415
|
+
const active = os.changes.filter(c => c.stage !== "archived");
|
|
3416
|
+
if (active.length > 0) {
|
|
3417
|
+
summaryLines.push(`📋 OpenSpec: ${active.length} active — ${active.map(c => c.name).join(", ")}`);
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
// Memory + embedding coverage
|
|
3422
|
+
const vecCount = store ? store.countFactVectors(mind) : 0;
|
|
3423
|
+
const coveragePct = factsAfter > 0 ? Math.round((vecCount / factsAfter) * 100) : 100;
|
|
3424
|
+
const embeddingInfo = embeddingAvailable
|
|
3425
|
+
? ` · ${coveragePct}% indexed`
|
|
3426
|
+
: " · semantic search off";
|
|
3427
|
+
const memLine = delta > 0
|
|
3428
|
+
? `🧠 ${factsAfter} facts (+${delta} new)${embeddingInfo}`
|
|
3429
|
+
: `🧠 ${factsAfter} facts${embeddingInfo}`;
|
|
3430
|
+
summaryLines.push(memLine);
|
|
3431
|
+
|
|
3432
|
+
if (summaryLines.length > 0) {
|
|
3433
|
+
ctx.ui.notify(summaryLines.join("\n"), "info");
|
|
3434
|
+
await new Promise(r => setTimeout(r, 300));
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
ctx.ui.notify("Goodbye!", "info");
|
|
3438
|
+
|
|
3439
|
+
// Small delay so the notification renders
|
|
3440
|
+
await new Promise(r => setTimeout(r, 200));
|
|
3441
|
+
|
|
3442
|
+
// ctx.shutdown() is fire-and-forget internally (sets shutdownRequested flag
|
|
3443
|
+
// and calls void this.shutdown() in interactive mode). We must keep this
|
|
3444
|
+
// command handler alive so control doesn't return to the REPL prompt —
|
|
3445
|
+
// otherwise the user sees the input prompt again instead of the process exiting.
|
|
3446
|
+
ctx.shutdown();
|
|
3447
|
+
|
|
3448
|
+
// Block until process.exit() is called by the shutdown flow.
|
|
3449
|
+
// The shutdown handler now only does JSONL export + DB close (fast),
|
|
3450
|
+
// since episode generation already completed above.
|
|
3451
|
+
await new Promise<void>(resolve => {
|
|
3452
|
+
setTimeout(() => {
|
|
3453
|
+
resolve();
|
|
3454
|
+
process.exit(0);
|
|
3455
|
+
}, 10_000);
|
|
3456
|
+
});
|
|
3457
|
+
},
|
|
3458
|
+
});
|
|
3459
|
+
}
|