@daniel156161/prism 0.2.81 → 0.2.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/dist/prism-extensions/integrations/ai-memory-errors.d.ts +13 -0
  2. package/dist/prism-extensions/integrations/ai-memory-errors.js +54 -0
  3. package/dist/prism-extensions/integrations/ai-memory-errors.js.map +1 -1
  4. package/dist/prism-extensions/integrations/ai-memory-http.d.ts +29 -0
  5. package/dist/prism-extensions/integrations/ai-memory-http.js +102 -0
  6. package/dist/prism-extensions/integrations/ai-memory-http.js.map +1 -0
  7. package/dist/prism-extensions/integrations/ai-memory-system.d.ts +3 -0
  8. package/dist/prism-extensions/integrations/ai-memory-system.js +63 -132
  9. package/dist/prism-extensions/integrations/ai-memory-system.js.map +1 -1
  10. package/dist/prism-extensions/integrations/ai-memory-write-preview.d.ts +36 -0
  11. package/dist/prism-extensions/integrations/ai-memory-write-preview.js +67 -0
  12. package/dist/prism-extensions/integrations/ai-memory-write-preview.js.map +1 -0
  13. package/dist/prism-extensions/ui/collapsed-text-rendering.d.ts +4 -2
  14. package/dist/prism-extensions/ui/collapsed-text-rendering.js +12 -7
  15. package/dist/prism-extensions/ui/collapsed-text-rendering.js.map +1 -1
  16. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js +2365 -2
  17. package/package.json +4 -3
  18. package/src/prism-extensions/integrations/ai-memory-errors.ts +59 -0
  19. package/src/prism-extensions/integrations/ai-memory-http.ts +123 -0
  20. package/src/prism-extensions/integrations/ai-memory-system.ts +74 -139
  21. package/src/prism-extensions/integrations/ai-memory-write-preview.ts +83 -0
  22. package/src/prism-extensions/ui/collapsed-text-rendering.ts +14 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daniel156161/prism",
3
- "version": "0.2.81",
3
+ "version": "0.2.83",
4
4
  "description": "Prism-branded wrapper around pi that stores config in ~/.prism",
5
5
  "type": "module",
6
6
  "engines": {
@@ -27,7 +27,8 @@
27
27
  "dependencies": {
28
28
  "@earendil-works/pi-ai": "^0.84.1",
29
29
  "@earendil-works/pi-coding-agent": "^0.84.1",
30
- "pi-mcp-adapter": "^2.21.1",
30
+ "eventsource": "^3.0.7",
31
+ "pi-mcp-adapter": "^2.21.2",
31
32
  "pkce-challenge": "^6.0.0",
32
33
  "typebox": "^1.3.11"
33
34
  },
@@ -36,7 +37,7 @@
36
37
  ],
37
38
  "devDependencies": {
38
39
  "@types/node": "^26.2.0",
39
- "tsx": "^4.23.11",
40
+ "tsx": "^4.23.12",
40
41
  "typescript": "^7.0.2"
41
42
  },
42
43
  "files": [
@@ -14,6 +14,11 @@ export type AiMemoryRequestContext = {
14
14
  filters?: Record<string, unknown>
15
15
  }
16
16
 
17
+ export type AiMemoryTransportContext = AiMemoryRequestContext & {
18
+ baseUrl: string
19
+ timeoutMs: number
20
+ }
21
+
17
22
  function parseBody(text: string): any {
18
23
  try {
19
24
  return JSON.parse(text)
@@ -44,6 +49,60 @@ export function formatApiFailure(status: number, text: string, context: AiMemory
44
49
  return parts.join(" | ")
45
50
  }
46
51
 
52
+ function errorCode(error: unknown): string | undefined {
53
+ const candidates = [error as any, (error as any)?.cause]
54
+ for (const candidate of candidates) {
55
+ const code = candidate?.code ?? candidate?.errno
56
+ if (typeof code === "string" && code.trim()) return code.trim()
57
+ }
58
+ return undefined
59
+ }
60
+
61
+ function errorMessage(error: unknown): string {
62
+ if (error instanceof Error) {
63
+ const cause = (error as any).cause
64
+ const causeMessage = cause instanceof Error ? cause.message : typeof cause === "string" ? cause : ""
65
+ return causeMessage && causeMessage !== error.message ? `${error.message}: ${causeMessage}` : error.message
66
+ }
67
+ return String(error)
68
+ }
69
+
70
+ /**
71
+ * A request that never reached the API (timeout, refused connection, DNS) must
72
+ * still explain itself. `fetch` alone only reports "fetch failed" or "The
73
+ * operation was aborted due to timeout", which is useless while debugging why a
74
+ * memory write did not land.
75
+ */
76
+ export function formatTransportFailure(error: unknown, context: AiMemoryTransportContext): string {
77
+ const name = (error as any)?.name
78
+ const code = errorCode(error)
79
+ const timedOut = name === "TimeoutError" || name === "AbortError"
80
+ const reason = timedOut
81
+ ? `request timed out after ${context.timeoutMs}ms`
82
+ : `request failed before a response arrived: ${errorMessage(error)}`
83
+
84
+ const parts = [`AI Memory ${context.method} ${context.path} failed: ${reason}`]
85
+ if (code) parts.push(`code=${code}`)
86
+ parts.push(`base_url=${context.baseUrl}`)
87
+ if (code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "EAI_AGAIN") {
88
+ parts.push(`hint=Is the AI Memory System API running and reachable at ${context.baseUrl}?`)
89
+ } else if (timedOut) {
90
+ parts.push("hint=Raise PRISM_AI_MEMORY_TIMEOUT_MS or check the API load; nothing was confirmed as written.")
91
+ }
92
+ if (context.query) parts.push(`query=${JSON.stringify(context.query)}`)
93
+ if (context.filters && Object.keys(context.filters).length > 0) parts.push(`filters=${JSON.stringify(context.filters)}`)
94
+ return parts.join(" | ")
95
+ }
96
+
97
+ /** A 2xx response with a body that is not JSON, e.g. an HTML proxy page. */
98
+ export function formatInvalidJsonFailure(error: unknown, text: string, context: AiMemoryTransportContext): string {
99
+ const snippet = String(text ?? "").replace(/\s+/g, " ").trim().slice(0, 300)
100
+ const parts = [`AI Memory ${context.method} ${context.path} failed: response was not valid JSON: ${errorMessage(error)}`]
101
+ parts.push(`base_url=${context.baseUrl}`)
102
+ parts.push(`body=${snippet || "(empty)"}`)
103
+ return parts.join(" | ")
104
+ }
105
+
47
106
  export function formatDegradedNotice(degraded: any): string {
48
107
  const stages: string[] = Array.isArray(degraded?.stages) ? degraded.stages : []
49
108
  if (stages.length === 0) return ""
@@ -0,0 +1,123 @@
1
+ /**
2
+ * HTTP transport for the AI Memory System integration.
3
+ *
4
+ * Every request funnels through `requestJson` so that transport failures
5
+ * (timeout, refused connection, unreadable or non-JSON body) end up as
6
+ * descriptive errors instead of a bare "fetch failed" / "The operation was
7
+ * aborted". The tool call is still reported as an error to the model, but the
8
+ * message now explains *why* it failed and which route/base URL was involved.
9
+ */
10
+
11
+ import { readSettings } from "../core/shared-config.js"
12
+ import { formatApiFailure, formatInvalidJsonFailure, formatTransportFailure, type AiMemoryRequestContext } from "./ai-memory-errors.js"
13
+
14
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
15
+ const DEFAULT_STATUS_TIMEOUT_MS = 1_500
16
+ const DEFAULT_INJECT_TIMEOUT_MS = 1_200
17
+ const DEFAULT_INJECT_CACHE_TTL_MS = 60_000
18
+
19
+ export function configuredAiMemoryBaseUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
20
+ const raw = env.PRISM_AI_MEMORY_BASE_URL ?? env.AI_MEMORY_BASE_URL ?? readSettings(undefined, env)?.aiMemory?.baseUrl ?? readSettings(undefined, env)?.aiMemoryBaseUrl
21
+ if (typeof raw !== "string") return undefined
22
+ const trimmed = raw.trim().replace(/\/+$/, "")
23
+ return trimmed || undefined
24
+ }
25
+
26
+ export function aiMemoryBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
27
+ const baseUrl = configuredAiMemoryBaseUrl(env)
28
+ if (!baseUrl) throw new Error("AI Memory base URL is not configured. Set PRISM_AI_MEMORY_BASE_URL or aiMemory.baseUrl.")
29
+ return baseUrl
30
+ }
31
+
32
+ function positiveIntegerSetting(value: unknown, fallback: number): number {
33
+ const parsed = Number(value)
34
+ if (!Number.isFinite(parsed)) return fallback
35
+ return Math.max(1, Math.trunc(parsed))
36
+ }
37
+
38
+ export function aiMemoryRequestTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
39
+ return positiveIntegerSetting(env.PRISM_AI_MEMORY_TIMEOUT_MS ?? env.PI_AI_MEMORY_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS)
40
+ }
41
+
42
+ export function aiMemoryStatusTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
43
+ return positiveIntegerSetting(env.PRISM_AI_MEMORY_STATUS_TIMEOUT_MS ?? env.PI_AI_MEMORY_STATUS_TIMEOUT_MS, DEFAULT_STATUS_TIMEOUT_MS)
44
+ }
45
+
46
+ export function aiMemoryInjectTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
47
+ return positiveIntegerSetting(env.PRISM_AI_MEMORY_INJECT_TIMEOUT_MS ?? env.PI_AI_MEMORY_INJECT_TIMEOUT_MS, DEFAULT_INJECT_TIMEOUT_MS)
48
+ }
49
+
50
+ export function aiMemoryInjectCacheTtlMs(env: NodeJS.ProcessEnv = process.env): number {
51
+ return positiveIntegerSetting(env.PRISM_AI_MEMORY_INJECT_CACHE_TTL_MS ?? env.PI_AI_MEMORY_INJECT_CACHE_TTL_MS, DEFAULT_INJECT_CACHE_TTL_MS)
52
+ }
53
+
54
+ export type AiMemoryRequest = {
55
+ method: string
56
+ path: string
57
+ body?: unknown
58
+ timeoutMs?: number
59
+ query?: string
60
+ filters?: Record<string, unknown>
61
+ }
62
+
63
+ function requestContext(request: AiMemoryRequest): AiMemoryRequestContext {
64
+ const payload = request.body as any
65
+ return {
66
+ method: request.method,
67
+ path: request.path,
68
+ query: request.query ?? (typeof payload?.query === "string" ? payload.query : undefined),
69
+ filters: request.filters ?? (payload?.filters as Record<string, unknown> | undefined),
70
+ }
71
+ }
72
+
73
+ export async function requestJson<T>(request: AiMemoryRequest): Promise<T> {
74
+ const baseUrl = aiMemoryBaseUrl()
75
+ const timeoutMs = request.timeoutMs ?? aiMemoryRequestTimeoutMs()
76
+ const context = requestContext(request)
77
+ const transport = { ...context, baseUrl, timeoutMs }
78
+
79
+ let response: Response
80
+ let text: string
81
+ try {
82
+ response = await fetch(`${baseUrl}${request.path}`, {
83
+ method: request.method,
84
+ headers: {
85
+ accept: "application/json",
86
+ ...(request.body === undefined ? {} : { "content-type": "application/json; charset=utf-8" }),
87
+ },
88
+ body: request.body === undefined ? undefined : JSON.stringify(request.body),
89
+ signal: AbortSignal.timeout(timeoutMs),
90
+ })
91
+ text = await response.text()
92
+ } catch (error) {
93
+ throw new Error(formatTransportFailure(error, transport))
94
+ }
95
+
96
+ if (!response.ok) throw new Error(formatApiFailure(response.status, text, context))
97
+
98
+ try {
99
+ return JSON.parse(text) as T
100
+ } catch (error) {
101
+ throw new Error(formatInvalidJsonFailure(error, text, transport))
102
+ }
103
+ }
104
+
105
+ export function postJson<T>(path: string, body: unknown, timeoutMs?: number): Promise<T> {
106
+ return requestJson<T>({ method: "POST", path, body, timeoutMs })
107
+ }
108
+
109
+ export function putJson<T>(path: string, body: unknown, timeoutMs?: number): Promise<T> {
110
+ return requestJson<T>({ method: "PUT", path, body, timeoutMs })
111
+ }
112
+
113
+ export function deleteJson<T>(path: string, body: unknown, timeoutMs?: number): Promise<T> {
114
+ return requestJson<T>({ method: "DELETE", path, body, timeoutMs })
115
+ }
116
+
117
+ export function methodJson<T>(method: string, path: string, timeoutMs?: number, body?: unknown): Promise<T> {
118
+ return requestJson<T>({ method, path, body, timeoutMs })
119
+ }
120
+
121
+ export function getJson<T>(path: string, timeoutMs: number = aiMemoryStatusTimeoutMs()): Promise<T> {
122
+ return requestJson<T>({ method: "GET", path, timeoutMs })
123
+ }
@@ -2,26 +2,40 @@ type ExtensionAPI = any
2
2
 
3
3
  import { Type } from "typebox"
4
4
  import { readSettings } from "../core/shared-config.js"
5
- import { formatApiFailure, formatDegradedNotice } from "./ai-memory-errors.js"
5
+ import { formatDegradedNotice } from "./ai-memory-errors.js"
6
+ import {
7
+ aiMemoryBaseUrl,
8
+ configuredAiMemoryBaseUrl,
9
+ aiMemoryInjectCacheTtlMs,
10
+ aiMemoryInjectTimeoutMs,
11
+ aiMemoryRequestTimeoutMs,
12
+ aiMemoryStatusTimeoutMs,
13
+ deleteJson,
14
+ getJson,
15
+ methodJson,
16
+ postJson,
17
+ putJson,
18
+ } from "./ai-memory-http.js"
19
+ import { isAiMemoryWriteTool, renderAiMemoryWriteResult } from "./ai-memory-write-preview.js"
6
20
  import { renderCollapsibleTextResult } from "../ui/collapsed-text-rendering.js"
7
21
  import { obsidianOpenUrl } from "./obsidian-memory.js"
8
22
  import { ansiHyperlink, formatBracketedToolCall, renderSingleLineToolCall, type ThemeLike } from "../ui/tool-call-rendering.js"
9
23
 
10
- const DEFAULT_BASE_URL = "http://127.0.0.1:8765"
11
24
  const DEFAULT_LIMIT = 8
12
25
  const DEFAULT_CONTEXT_LIMIT = 5
13
26
  const DEFAULT_CONTEXT_SCORE_THRESHOLD = 0.15
14
- const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
15
- const DEFAULT_STATUS_TIMEOUT_MS = 1_500
16
- const DEFAULT_INJECT_TIMEOUT_MS = 1_200
17
- const DEFAULT_INJECT_CACHE_TTL_MS = 60_000
18
27
  const DEFAULT_ALWAYS_CONTEXT_CACHE_TTL_MS = 60_000
19
28
 
20
29
  const injectCache = new Map<string, { expiresAt: number; results: any[] }>()
21
30
  const alwaysContextCache = { expiresAt: 0, content: "" }
31
+ const reportedBackgroundFailures = new Set<string>()
22
32
 
23
- function aiMemoryBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
24
- return (env.PRISM_AI_MEMORY_BASE_URL || env.AI_MEMORY_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "")
33
+ /** Test hook: drop injection caches and one-shot failure notices. */
34
+ export function resetAiMemoryCaches(): void {
35
+ injectCache.clear()
36
+ alwaysContextCache.expiresAt = 0
37
+ alwaysContextCache.content = ""
38
+ reportedBackgroundFailures.clear()
25
39
  }
26
40
 
27
41
  function clampLimit(value: unknown, fallback = DEFAULT_LIMIT): number {
@@ -50,7 +64,7 @@ export function shouldEnableAiMemory(env: NodeJS.ProcessEnv = process.env): bool
50
64
  if (envValue !== undefined) return envValue
51
65
  const settings = readSettings(undefined, env)
52
66
  const settingsValue = booleanSetting(settings?.aiMemory?.enabled ?? settings?.aiMemoryEnabled)
53
- return settingsValue ?? true
67
+ return settingsValue ?? configuredAiMemoryBaseUrl(env) !== undefined
54
68
  }
55
69
 
56
70
  export function shouldInjectAiMemoryCandidates(env: NodeJS.ProcessEnv = process.env): boolean {
@@ -58,7 +72,15 @@ export function shouldInjectAiMemoryCandidates(env: NodeJS.ProcessEnv = process.
58
72
  if (envValue !== undefined) return envValue
59
73
  const settings = readSettings(undefined, env)
60
74
  const settingsValue = booleanSetting(settings?.aiMemory?.injectCandidates ?? settings?.aiMemoryInjectCandidates)
61
- return settingsValue ?? true
75
+ return settingsValue ?? configuredAiMemoryBaseUrl(env) !== undefined
76
+ }
77
+
78
+ export function shouldInjectAiMemoryAlwaysContext(env: NodeJS.ProcessEnv = process.env): boolean {
79
+ const envValue = booleanSetting(env.PRISM_AI_MEMORY_INJECT_ALWAYS_CONTEXT ?? env.PI_AI_MEMORY_INJECT_ALWAYS_CONTEXT)
80
+ if (envValue !== undefined) return envValue
81
+ const settings = readSettings(undefined, env)
82
+ const settingsValue = booleanSetting(settings?.aiMemory?.injectAlwaysContext ?? settings?.aiMemoryInjectAlwaysContext)
83
+ return settingsValue ?? configuredAiMemoryBaseUrl(env) !== undefined
62
84
  }
63
85
 
64
86
  function aiMemoryContextLimit(env: NodeJS.ProcessEnv = process.env): number {
@@ -70,28 +92,6 @@ function aiMemoryContextScoreThreshold(env: NodeJS.ProcessEnv = process.env): nu
70
92
  return Number.isFinite(parsed) ? Math.max(0, parsed) : DEFAULT_CONTEXT_SCORE_THRESHOLD
71
93
  }
72
94
 
73
- function positiveIntegerSetting(value: unknown, fallback: number): number {
74
- const parsed = Number(value)
75
- if (!Number.isFinite(parsed)) return fallback
76
- return Math.max(1, Math.trunc(parsed))
77
- }
78
-
79
- function aiMemoryRequestTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
80
- return positiveIntegerSetting(env.PRISM_AI_MEMORY_TIMEOUT_MS ?? env.PI_AI_MEMORY_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS)
81
- }
82
-
83
- function aiMemoryStatusTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
84
- return positiveIntegerSetting(env.PRISM_AI_MEMORY_STATUS_TIMEOUT_MS ?? env.PI_AI_MEMORY_STATUS_TIMEOUT_MS, DEFAULT_STATUS_TIMEOUT_MS)
85
- }
86
-
87
- function aiMemoryInjectTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
88
- return positiveIntegerSetting(env.PRISM_AI_MEMORY_INJECT_TIMEOUT_MS ?? env.PI_AI_MEMORY_INJECT_TIMEOUT_MS, DEFAULT_INJECT_TIMEOUT_MS)
89
- }
90
-
91
- function aiMemoryInjectCacheTtlMs(env: NodeJS.ProcessEnv = process.env): number {
92
- return positiveIntegerSetting(env.PRISM_AI_MEMORY_INJECT_CACHE_TTL_MS ?? env.PI_AI_MEMORY_INJECT_CACHE_TTL_MS, DEFAULT_INJECT_CACHE_TTL_MS)
93
- }
94
-
95
95
  function cleanFilters(params: any): Record<string, string> {
96
96
  const filters: Record<string, string> = {}
97
97
  for (const key of ["source", "project", "type", "status", "tag", "path"]) {
@@ -101,82 +101,6 @@ function cleanFilters(params: any): Record<string, string> {
101
101
  return filters
102
102
  }
103
103
 
104
- async function postJson<T>(path: string, body: unknown, timeoutMs = aiMemoryRequestTimeoutMs()): Promise<T> {
105
- const baseUrl = aiMemoryBaseUrl()
106
- const response = await fetch(`${baseUrl}${path}`, {
107
- method: "POST",
108
- headers: { "content-type": "application/json; charset=utf-8", accept: "application/json" },
109
- body: JSON.stringify(body),
110
- signal: AbortSignal.timeout(timeoutMs),
111
- })
112
- const text = await response.text()
113
- if (!response.ok) {
114
- const payload = body as any
115
- throw new Error(formatApiFailure(response.status, text, {
116
- method: "POST",
117
- path,
118
- query: typeof payload?.query === "string" ? payload.query : undefined,
119
- filters: payload?.filters,
120
- }))
121
- }
122
- return JSON.parse(text) as T
123
- }
124
-
125
- async function putJson<T>(path: string, body: unknown, timeoutMs = aiMemoryRequestTimeoutMs()): Promise<T> {
126
- const baseUrl = aiMemoryBaseUrl()
127
- const response = await fetch(`${baseUrl}${path}`, {
128
- method: "PUT",
129
- headers: { "content-type": "application/json; charset=utf-8", accept: "application/json" },
130
- body: JSON.stringify(body),
131
- signal: AbortSignal.timeout(timeoutMs),
132
- })
133
- const text = await response.text()
134
- if (!response.ok) throw new Error(formatApiFailure(response.status, text, { method: "PUT", path }))
135
- return JSON.parse(text) as T
136
- }
137
-
138
- async function deleteJson<T>(path: string, body: unknown, timeoutMs = aiMemoryRequestTimeoutMs()): Promise<T> {
139
- const baseUrl = aiMemoryBaseUrl()
140
- const response = await fetch(`${baseUrl}${path}`, {
141
- method: "DELETE",
142
- headers: { "content-type": "application/json; charset=utf-8", accept: "application/json" },
143
- body: JSON.stringify(body),
144
- signal: AbortSignal.timeout(timeoutMs),
145
- })
146
- const text = await response.text()
147
- if (!response.ok) throw new Error(formatApiFailure(response.status, text, { method: "DELETE", path }))
148
- return JSON.parse(text) as T
149
- }
150
-
151
- async function methodJson<T>(
152
- method: string,
153
- path: string,
154
- timeoutMs = aiMemoryRequestTimeoutMs(),
155
- body?: unknown,
156
- ): Promise<T> {
157
- const baseUrl = aiMemoryBaseUrl()
158
- const response = await fetch(`${baseUrl}${path}`, {
159
- method,
160
- headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json; charset=utf-8" }) },
161
- body: body === undefined ? undefined : JSON.stringify(body),
162
- signal: AbortSignal.timeout(timeoutMs),
163
- })
164
- const text = await response.text()
165
- if (!response.ok) throw new Error(formatApiFailure(response.status, text, { method, path }))
166
- return JSON.parse(text) as T
167
- }
168
-
169
- async function getJson<T>(path: string, timeoutMs = aiMemoryStatusTimeoutMs()): Promise<T> {
170
- const baseUrl = aiMemoryBaseUrl()
171
- const response = await fetch(`${baseUrl}${path}`, {
172
- headers: { accept: "application/json" },
173
- signal: AbortSignal.timeout(timeoutMs),
174
- })
175
- const text = await response.text()
176
- if (!response.ok) throw new Error(formatApiFailure(response.status, text, { method: "GET", path }))
177
- return JSON.parse(text) as T
178
- }
179
-
180
104
  function formatSearchResults(results: any[]): string {
181
105
  if (!Array.isArray(results) || results.length === 0) return "No AI Memory results found. Is the index built?"
182
106
  return results.map((row, index) => {
@@ -299,13 +223,25 @@ export function formatAiMemoryToolCall(toolName: string, args: any, theme: Theme
299
223
  const label = toolName.replace(/^ai_memory_/, "ai memory ").replace(/_/g, " ")
300
224
  const rawValue = args?.query || args?.id || args?.path || args?.session || ""
301
225
  const value = typeof rawValue === "string" && rawValue.trim() ? rawValue.trim() : "..."
302
- const linksToVaultNote = ["ai_memory_vault_write", "ai_memory_vault_edit", "ai_memory_vault_delete"].includes(toolName)
226
+ const linksToVaultNote = isAiMemoryWriteTool(toolName) || toolName === "ai_memory_vault_delete"
303
227
  if (linksToVaultNote && value !== "...") {
304
228
  return `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", ansiHyperlink(obsidianOpenUrl(value), value))}`
305
229
  }
306
230
  return formatBracketedToolCall(label, value, theme)
307
231
  }
308
232
 
233
+ /**
234
+ * Surface background (non-tool) failures once per distinct reason. The dedupe key
235
+ * ignores the per-turn query so a broken API does not warn on every prompt.
236
+ */
237
+ function reportBackgroundFailure(ctx: any, label: string, error: unknown): void {
238
+ const message = `AI Memory ${label} failed: ${error instanceof Error ? error.message : String(error)}`
239
+ const key = `${label}|${message.split(" | query=")[0]}`
240
+ if (reportedBackgroundFailures.has(key)) return
241
+ reportedBackgroundFailures.add(key)
242
+ ctx?.ui?.notify?.(message, "warning")
243
+ }
244
+
309
245
  function formatVaultWriteResult(action: "Wrote" | "Edited", params: any, responsePath: unknown): string {
310
246
  const path = String(responsePath ?? params.path ?? "")
311
247
  const frontmatter = params.frontmatter === undefined ? "" : String(params.frontmatter).trim()
@@ -571,8 +507,8 @@ export default function aiMemorySystemExtension(pi: ExtensionAPI): void {
571
507
  renderCall(args: any, theme: ThemeLike, context: any) {
572
508
  return renderSingleLineToolCall(formatAiMemoryToolCall("ai_memory_vault_write", args, theme), context)
573
509
  },
574
- renderResult(result: any, options: any, theme: ThemeLike) {
575
- return renderAiMemoryResult(result, options, theme)
510
+ renderResult(result: any, options: any, theme: ThemeLike, context: any) {
511
+ return renderAiMemoryWriteResult("ai_memory_vault_write", result, options, theme, context)
576
512
  },
577
513
  })
578
514
 
@@ -599,8 +535,8 @@ export default function aiMemorySystemExtension(pi: ExtensionAPI): void {
599
535
  renderCall(args: any, theme: ThemeLike, context: any) {
600
536
  return renderSingleLineToolCall(formatAiMemoryToolCall("ai_memory_vault_edit", args, theme), context)
601
537
  },
602
- renderResult(result: any, options: any, theme: ThemeLike) {
603
- return renderAiMemoryResult(result, options, theme)
538
+ renderResult(result: any, options: any, theme: ThemeLike, context: any) {
539
+ return renderAiMemoryWriteResult("ai_memory_vault_edit", result, options, theme, context)
604
540
  },
605
541
  })
606
542
 
@@ -646,36 +582,35 @@ export default function aiMemorySystemExtension(pi: ExtensionAPI): void {
646
582
  .catch(() => ctx.ui.setStatus?.("ai-memory", undefined))
647
583
  })
648
584
 
649
- pi.on?.("before_agent_start", async (event: any) => {
650
- // Always-loaded durable context (mapped from 00 Kontext) is injected as a
651
- // system-prompt chunk so it remains in the cached prefix.
652
- let alwaysBlock: string | undefined
653
- try {
654
- const alwaysContent = await loadAlwaysContext()
655
- const alwaysMessage = buildAlwaysContextMessage(alwaysContent)
656
- alwaysBlock = alwaysMessage?.content
657
- } catch {
658
- // Ignore — always-context is best-effort.
659
- }
585
+ pi.on?.("before_agent_start", async (event: any, ctx: any) => {
586
+ const patch: { systemPrompt?: string; message?: any } = {}
660
587
 
661
- const prev = String(event?.systemPrompt ?? "")
662
- if (alwaysBlock && alwaysBlock.trim()) {
663
- const block = alwaysBlock.trim()
664
- return { systemPrompt: `${prev}${prev ? "\n\n" : ""}${block}` }
588
+ // Always-loaded durable context (mapped from 00 Kontext) is injected as a
589
+ // system-prompt chunk so it remains in the cached prefix. It is opt-in so a
590
+ // missing local AI Memory daemon does not produce startup/per-turn warnings.
591
+ if (shouldInjectAiMemoryAlwaysContext()) {
592
+ try {
593
+ const alwaysBlock = buildAlwaysContextMessage(await loadAlwaysContext())?.content?.trim()
594
+ if (alwaysBlock) {
595
+ const prev = String(event?.systemPrompt ?? "")
596
+ patch.systemPrompt = `${prev}${prev ? "\n\n" : ""}${alwaysBlock}`
597
+ }
598
+ } catch (error) {
599
+ reportBackgroundFailure(ctx, "always-context injection", error)
600
+ }
665
601
  }
666
602
 
667
- // Query-specific candidates
668
- if (!shouldInjectAiMemoryCandidates()) return
603
+ // Query-specific candidates (independent of the always-context block).
669
604
  const query = String(event?.prompt ?? "").trim()
670
- if (!query) return
671
- try {
672
- const results = await loadAiMemoryInjectResults(query)
673
- const message = buildAiMemoryContextMessage(results)
674
- return message ? { message } : undefined
675
- } catch {
676
- // Candidate injection is opportunistic. The explicit ai_memory_search tool
677
- // remains available and will surface API errors when the model calls it.
678
- return undefined
605
+ if (shouldInjectAiMemoryCandidates() && query) {
606
+ try {
607
+ const message = buildAiMemoryContextMessage(await loadAiMemoryInjectResults(query))
608
+ if (message) patch.message = message
609
+ } catch (error) {
610
+ reportBackgroundFailure(ctx, "candidate injection", error)
611
+ }
679
612
  }
613
+
614
+ return patch.systemPrompt === undefined && patch.message === undefined ? undefined : patch
680
615
  })
681
616
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Live preview and rendering of memory writes.
3
+ *
4
+ * Vault writes used to be invisible until the tool finished: the call row only
5
+ * showed the path and the result slot said "Searching AI Memory...". This module
6
+ * formats the *partially streamed* arguments so the note content is readable
7
+ * while the model is still producing it, and keeps the row expandable with
8
+ * Ctrl+O / Strg+O (`app.tools.expand`) in both states.
9
+ */
10
+
11
+ import { renderCollapsibleText, renderCollapsibleTextResult } from "../ui/collapsed-text-rendering.js"
12
+ import { truncateToWidth, type ThemeLike } from "../ui/tool-call-rendering.js"
13
+
14
+ /** Collapsed height while arguments stream in; the newest lines stay visible. */
15
+ export const WRITE_STREAM_COLLAPSED_LINES = 16
16
+ /** Collapsed height of a finished write result. */
17
+ export const WRITE_RESULT_COLLAPSED_LINES = 24
18
+
19
+ const WRITE_PREVIEW_TOOLS = new Set(["ai_memory_vault_write", "ai_memory_vault_edit"])
20
+
21
+ export function isAiMemoryWriteTool(toolName: string): boolean {
22
+ return WRITE_PREVIEW_TOOLS.has(toolName)
23
+ }
24
+
25
+ function text(value: unknown): string {
26
+ return value === undefined || value === null ? "" : String(value)
27
+ }
28
+
29
+ export function formatAiMemoryWriteTarget(toolName: string, args: any): string {
30
+ const path = text(args?.path).trim() || "(path pending)"
31
+ const heading = text(args?.heading).trim()
32
+ return toolName === "ai_memory_vault_edit" && heading ? `${path} > # ${heading}` : path
33
+ }
34
+
35
+ /**
36
+ * @param args partially streamed tool arguments (may miss fields entirely)
37
+ * @param streaming true while arguments are still arriving
38
+ */
39
+ export function buildAiMemoryWritePreview(toolName: string, args: any, streaming = true): { header: string; content: string } {
40
+ const label = toolName === "ai_memory_vault_edit" ? "Editing" : "Writing"
41
+ const frontmatter = text(args?.frontmatter).trim()
42
+ const heading = text(args?.heading).trim()
43
+ const headingLine = toolName === "ai_memory_vault_edit" && heading ? `# ${heading}` : ""
44
+ const body = text(args?.body)
45
+ const written = [frontmatter, headingLine, body.trimEnd()].filter(Boolean).join("\n\n")
46
+
47
+ const header = `${label}: ${formatAiMemoryWriteTarget(toolName, args)}${streaming ? " … streaming" : ""}`
48
+ const content = written || (streaming ? "(waiting for content…)" : "(empty)")
49
+ return { header, content }
50
+ }
51
+
52
+ export function formatAiMemoryWritePreview(toolName: string, args: any, streaming = true): string {
53
+ const { header, content } = buildAiMemoryWritePreview(toolName, args, streaming)
54
+ return `${header}\n\n${content}`
55
+ }
56
+
57
+ /**
58
+ * Result slot for vault writes: streams the note content while the model writes,
59
+ * shows the confirmed content afterwards. Both states honour `expanded`, so
60
+ * Ctrl+O / Strg+O toggles between the collapsed excerpt and the full text.
61
+ */
62
+ export function renderAiMemoryWriteResult(
63
+ toolName: string,
64
+ result: any,
65
+ options: { expanded?: boolean; isPartial?: boolean } = {},
66
+ theme: ThemeLike,
67
+ context?: any,
68
+ ): { render(width: number): string[] } {
69
+ if (!options.isPartial) {
70
+ return renderCollapsibleTextResult(result, options, theme, {
71
+ partialLabel: "Writing AI Memory...",
72
+ emptyLabel: "No AI Memory write result",
73
+ maxLines: WRITE_RESULT_COLLAPSED_LINES,
74
+ })
75
+ }
76
+
77
+ const { header, content } = buildAiMemoryWritePreview(toolName, context?.args ?? {}, context?.argsComplete !== true)
78
+ // Keep the target pinned above the streamed tail so it never scrolls away.
79
+ const body = renderCollapsibleText(content, options, theme, { maxLines: WRITE_STREAM_COLLAPSED_LINES, keep: "tail" })
80
+ return {
81
+ render: (width: number) => [theme.fg("accent", truncateToWidth(header, Math.max(10, width - 2))), ...body.render(width)],
82
+ }
83
+ }
@@ -9,6 +9,8 @@ export type CollapsibleTextRenderConfig = {
9
9
  partialLabel: string
10
10
  emptyLabel: string
11
11
  maxLines: number
12
+ /** "head" keeps the first lines (default), "tail" keeps the newest lines. */
13
+ keep?: "head" | "tail"
12
14
  lines?: (text: string) => string[]
13
15
  strip?: (text: string) => string
14
16
  }
@@ -31,26 +33,30 @@ export function renderCollapsibleText(
31
33
  text: string,
32
34
  options: CollapsibleTextRenderOptions = {},
33
35
  theme: ThemeLike,
34
- config: Pick<CollapsibleTextRenderConfig, "maxLines" | "lines" | "strip">,
36
+ config: Pick<CollapsibleTextRenderConfig, "maxLines" | "keep" | "lines" | "strip">,
35
37
  ): { render(width: number): string[] } {
36
38
  const body = config.strip ? config.strip(text) : text
37
39
  const lines = config.lines ? config.lines(body) : body.split(/\r?\n/)
38
- const shown = options.expanded ? lines : lines.slice(0, config.maxLines)
40
+ const keepTail = config.keep === "tail"
41
+ const shown = options.expanded ? lines : keepTail ? lines.slice(-config.maxLines) : lines.slice(0, config.maxLines)
42
+ const hidden = lines.length - shown.length
39
43
 
40
44
  return {
41
45
  render: (width: number) => {
42
46
  const maxWidth = Math.max(10, width - 2)
47
+ const hint = hidden > 0 && !options.expanded
48
+ ? theme.fg("muted", truncateToWidth(collapseHint(hidden, keepTail ? "above" : "below"), maxWidth))
49
+ : undefined
43
50
  const rendered = shown.map((line) => theme.fg("dim", truncateToWidth(line, maxWidth)))
44
- if (!options.expanded && lines.length > shown.length) {
45
- rendered.push(theme.fg("muted", truncateToWidth(collapseHint(lines.length - shown.length), maxWidth)))
46
- }
47
- return rendered
51
+ if (!hint) return rendered
52
+ return keepTail ? [hint, ...rendered] : [...rendered, hint]
48
53
  },
49
54
  }
50
55
  }
51
56
 
52
- export function collapseHint(hiddenLineCount: number): string {
53
- return `... ${hiddenLineCount} more lines (Ctrl+O / Strg+O to expand)`
57
+ export function collapseHint(hiddenLineCount: number, position: "below" | "above" = "below"): string {
58
+ const what = position === "above" ? "earlier lines" : "more lines"
59
+ return `... ${hiddenLineCount} ${what} (Ctrl+O / Strg+O to expand)`
54
60
  }
55
61
 
56
62
  export function stripLeadingPathHeader(text: string, relPath: string): string {