@daniel156161/prism 0.2.81 → 0.2.82

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 (35) 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 +28 -0
  5. package/dist/prism-extensions/integrations/ai-memory-http.js +92 -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 +2 -0
  8. package/dist/prism-extensions/integrations/ai-memory-system.js +49 -127
  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/cli.js +1 -1
  17. package/node_modules/@earendil-works/pi-coding-agent/dist/config.js +11 -6
  18. package/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session-services.js +1 -1
  19. package/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js +0 -5
  20. package/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-storage.js +1 -1
  21. package/node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js +1 -3
  22. package/node_modules/@earendil-works/pi-coding-agent/dist/core/project-trust.js +1 -1
  23. package/node_modules/@earendil-works/pi-coding-agent/dist/core/sdk.js +1 -1
  24. package/node_modules/@earendil-works/pi-coding-agent/dist/core/session-manager.js +37 -39
  25. package/node_modules/@earendil-works/pi-coding-agent/dist/core/system-prompt.js +16 -20
  26. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/session-selector.js +0 -4
  27. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js +2376 -234
  28. package/node_modules/@earendil-works/pi-tui/dist/autocomplete.js +1 -1
  29. package/package.json +3 -3
  30. package/src/prism-extensions/integrations/ai-memory-errors.ts +59 -0
  31. package/src/prism-extensions/integrations/ai-memory-http.ts +114 -0
  32. package/src/prism-extensions/integrations/ai-memory-system.ts +60 -135
  33. package/src/prism-extensions/integrations/ai-memory-write-preview.ts +83 -0
  34. package/src/prism-extensions/ui/collapsed-text-rendering.ts +14 -8
  35. package/node_modules/@earendil-works/pi-coding-agent/dist/core/prism-session-db.js +0 -128
@@ -202,7 +202,7 @@ export class CombinedAutocompleteProvider {
202
202
  prefix: atPrefix,
203
203
  };
204
204
  }
205
- if (textBeforeCursor.startsWith("/")) {
205
+ if (!options.force && textBeforeCursor.startsWith("/")) {
206
206
  const spaceIndex = textBeforeCursor.indexOf(" ");
207
207
  if (spaceIndex === -1) {
208
208
  const prefix = textBeforeCursor.slice(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daniel156161/prism",
3
- "version": "0.2.81",
3
+ "version": "0.2.82",
4
4
  "description": "Prism-branded wrapper around pi that stores config in ~/.prism",
5
5
  "type": "module",
6
6
  "engines": {
@@ -27,7 +27,7 @@
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
+ "pi-mcp-adapter": "^2.21.2",
31
31
  "pkce-challenge": "^6.0.0",
32
32
  "typebox": "^1.3.11"
33
33
  },
@@ -36,7 +36,7 @@
36
36
  ],
37
37
  "devDependencies": {
38
38
  "@types/node": "^26.2.0",
39
- "tsx": "^4.23.11",
39
+ "tsx": "^4.23.12",
40
40
  "typescript": "^7.0.2"
41
41
  },
42
42
  "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,114 @@
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 { formatApiFailure, formatInvalidJsonFailure, formatTransportFailure, type AiMemoryRequestContext } from "./ai-memory-errors.js"
12
+
13
+ const DEFAULT_BASE_URL = "http://127.0.0.1:8765"
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 aiMemoryBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
20
+ return (env.PRISM_AI_MEMORY_BASE_URL || env.AI_MEMORY_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "")
21
+ }
22
+
23
+ function positiveIntegerSetting(value: unknown, fallback: number): number {
24
+ const parsed = Number(value)
25
+ if (!Number.isFinite(parsed)) return fallback
26
+ return Math.max(1, Math.trunc(parsed))
27
+ }
28
+
29
+ export function aiMemoryRequestTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
30
+ return positiveIntegerSetting(env.PRISM_AI_MEMORY_TIMEOUT_MS ?? env.PI_AI_MEMORY_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS)
31
+ }
32
+
33
+ export function aiMemoryStatusTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
34
+ return positiveIntegerSetting(env.PRISM_AI_MEMORY_STATUS_TIMEOUT_MS ?? env.PI_AI_MEMORY_STATUS_TIMEOUT_MS, DEFAULT_STATUS_TIMEOUT_MS)
35
+ }
36
+
37
+ export function aiMemoryInjectTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
38
+ return positiveIntegerSetting(env.PRISM_AI_MEMORY_INJECT_TIMEOUT_MS ?? env.PI_AI_MEMORY_INJECT_TIMEOUT_MS, DEFAULT_INJECT_TIMEOUT_MS)
39
+ }
40
+
41
+ export function aiMemoryInjectCacheTtlMs(env: NodeJS.ProcessEnv = process.env): number {
42
+ return positiveIntegerSetting(env.PRISM_AI_MEMORY_INJECT_CACHE_TTL_MS ?? env.PI_AI_MEMORY_INJECT_CACHE_TTL_MS, DEFAULT_INJECT_CACHE_TTL_MS)
43
+ }
44
+
45
+ export type AiMemoryRequest = {
46
+ method: string
47
+ path: string
48
+ body?: unknown
49
+ timeoutMs?: number
50
+ query?: string
51
+ filters?: Record<string, unknown>
52
+ }
53
+
54
+ function requestContext(request: AiMemoryRequest): AiMemoryRequestContext {
55
+ const payload = request.body as any
56
+ return {
57
+ method: request.method,
58
+ path: request.path,
59
+ query: request.query ?? (typeof payload?.query === "string" ? payload.query : undefined),
60
+ filters: request.filters ?? (payload?.filters as Record<string, unknown> | undefined),
61
+ }
62
+ }
63
+
64
+ export async function requestJson<T>(request: AiMemoryRequest): Promise<T> {
65
+ const baseUrl = aiMemoryBaseUrl()
66
+ const timeoutMs = request.timeoutMs ?? aiMemoryRequestTimeoutMs()
67
+ const context = requestContext(request)
68
+ const transport = { ...context, baseUrl, timeoutMs }
69
+
70
+ let response: Response
71
+ let text: string
72
+ try {
73
+ response = await fetch(`${baseUrl}${request.path}`, {
74
+ method: request.method,
75
+ headers: {
76
+ accept: "application/json",
77
+ ...(request.body === undefined ? {} : { "content-type": "application/json; charset=utf-8" }),
78
+ },
79
+ body: request.body === undefined ? undefined : JSON.stringify(request.body),
80
+ signal: AbortSignal.timeout(timeoutMs),
81
+ })
82
+ text = await response.text()
83
+ } catch (error) {
84
+ throw new Error(formatTransportFailure(error, transport))
85
+ }
86
+
87
+ if (!response.ok) throw new Error(formatApiFailure(response.status, text, context))
88
+
89
+ try {
90
+ return JSON.parse(text) as T
91
+ } catch (error) {
92
+ throw new Error(formatInvalidJsonFailure(error, text, transport))
93
+ }
94
+ }
95
+
96
+ export function postJson<T>(path: string, body: unknown, timeoutMs?: number): Promise<T> {
97
+ return requestJson<T>({ method: "POST", path, body, timeoutMs })
98
+ }
99
+
100
+ export function putJson<T>(path: string, body: unknown, timeoutMs?: number): Promise<T> {
101
+ return requestJson<T>({ method: "PUT", path, body, timeoutMs })
102
+ }
103
+
104
+ export function deleteJson<T>(path: string, body: unknown, timeoutMs?: number): Promise<T> {
105
+ return requestJson<T>({ method: "DELETE", path, body, timeoutMs })
106
+ }
107
+
108
+ export function methodJson<T>(method: string, path: string, timeoutMs?: number, body?: unknown): Promise<T> {
109
+ return requestJson<T>({ method, path, body, timeoutMs })
110
+ }
111
+
112
+ export function getJson<T>(path: string, timeoutMs: number = aiMemoryStatusTimeoutMs()): Promise<T> {
113
+ return requestJson<T>({ method: "GET", path, timeoutMs })
114
+ }
@@ -2,26 +2,39 @@ 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
+ aiMemoryInjectCacheTtlMs,
9
+ aiMemoryInjectTimeoutMs,
10
+ aiMemoryRequestTimeoutMs,
11
+ aiMemoryStatusTimeoutMs,
12
+ deleteJson,
13
+ getJson,
14
+ methodJson,
15
+ postJson,
16
+ putJson,
17
+ } from "./ai-memory-http.js"
18
+ import { isAiMemoryWriteTool, renderAiMemoryWriteResult } from "./ai-memory-write-preview.js"
6
19
  import { renderCollapsibleTextResult } from "../ui/collapsed-text-rendering.js"
7
20
  import { obsidianOpenUrl } from "./obsidian-memory.js"
8
21
  import { ansiHyperlink, formatBracketedToolCall, renderSingleLineToolCall, type ThemeLike } from "../ui/tool-call-rendering.js"
9
22
 
10
- const DEFAULT_BASE_URL = "http://127.0.0.1:8765"
11
23
  const DEFAULT_LIMIT = 8
12
24
  const DEFAULT_CONTEXT_LIMIT = 5
13
25
  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
26
  const DEFAULT_ALWAYS_CONTEXT_CACHE_TTL_MS = 60_000
19
27
 
20
28
  const injectCache = new Map<string, { expiresAt: number; results: any[] }>()
21
29
  const alwaysContextCache = { expiresAt: 0, content: "" }
30
+ const reportedBackgroundFailures = new Set<string>()
22
31
 
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(/\/+$/, "")
32
+ /** Test hook: drop injection caches and one-shot failure notices. */
33
+ export function resetAiMemoryCaches(): void {
34
+ injectCache.clear()
35
+ alwaysContextCache.expiresAt = 0
36
+ alwaysContextCache.content = ""
37
+ reportedBackgroundFailures.clear()
25
38
  }
26
39
 
27
40
  function clampLimit(value: unknown, fallback = DEFAULT_LIMIT): number {
@@ -70,28 +83,6 @@ function aiMemoryContextScoreThreshold(env: NodeJS.ProcessEnv = process.env): nu
70
83
  return Number.isFinite(parsed) ? Math.max(0, parsed) : DEFAULT_CONTEXT_SCORE_THRESHOLD
71
84
  }
72
85
 
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
86
  function cleanFilters(params: any): Record<string, string> {
96
87
  const filters: Record<string, string> = {}
97
88
  for (const key of ["source", "project", "type", "status", "tag", "path"]) {
@@ -101,82 +92,6 @@ function cleanFilters(params: any): Record<string, string> {
101
92
  return filters
102
93
  }
103
94
 
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
95
  function formatSearchResults(results: any[]): string {
181
96
  if (!Array.isArray(results) || results.length === 0) return "No AI Memory results found. Is the index built?"
182
97
  return results.map((row, index) => {
@@ -299,13 +214,25 @@ export function formatAiMemoryToolCall(toolName: string, args: any, theme: Theme
299
214
  const label = toolName.replace(/^ai_memory_/, "ai memory ").replace(/_/g, " ")
300
215
  const rawValue = args?.query || args?.id || args?.path || args?.session || ""
301
216
  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)
217
+ const linksToVaultNote = isAiMemoryWriteTool(toolName) || toolName === "ai_memory_vault_delete"
303
218
  if (linksToVaultNote && value !== "...") {
304
219
  return `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", ansiHyperlink(obsidianOpenUrl(value), value))}`
305
220
  }
306
221
  return formatBracketedToolCall(label, value, theme)
307
222
  }
308
223
 
224
+ /**
225
+ * Surface background (non-tool) failures once per distinct reason. The dedupe key
226
+ * ignores the per-turn query so a broken API does not warn on every prompt.
227
+ */
228
+ function reportBackgroundFailure(ctx: any, label: string, error: unknown): void {
229
+ const message = `AI Memory ${label} failed: ${error instanceof Error ? error.message : String(error)}`
230
+ const key = `${label}|${message.split(" | query=")[0]}`
231
+ if (reportedBackgroundFailures.has(key)) return
232
+ reportedBackgroundFailures.add(key)
233
+ ctx?.ui?.notify?.(message, "warning")
234
+ }
235
+
309
236
  function formatVaultWriteResult(action: "Wrote" | "Edited", params: any, responsePath: unknown): string {
310
237
  const path = String(responsePath ?? params.path ?? "")
311
238
  const frontmatter = params.frontmatter === undefined ? "" : String(params.frontmatter).trim()
@@ -571,8 +498,8 @@ export default function aiMemorySystemExtension(pi: ExtensionAPI): void {
571
498
  renderCall(args: any, theme: ThemeLike, context: any) {
572
499
  return renderSingleLineToolCall(formatAiMemoryToolCall("ai_memory_vault_write", args, theme), context)
573
500
  },
574
- renderResult(result: any, options: any, theme: ThemeLike) {
575
- return renderAiMemoryResult(result, options, theme)
501
+ renderResult(result: any, options: any, theme: ThemeLike, context: any) {
502
+ return renderAiMemoryWriteResult("ai_memory_vault_write", result, options, theme, context)
576
503
  },
577
504
  })
578
505
 
@@ -599,8 +526,8 @@ export default function aiMemorySystemExtension(pi: ExtensionAPI): void {
599
526
  renderCall(args: any, theme: ThemeLike, context: any) {
600
527
  return renderSingleLineToolCall(formatAiMemoryToolCall("ai_memory_vault_edit", args, theme), context)
601
528
  },
602
- renderResult(result: any, options: any, theme: ThemeLike) {
603
- return renderAiMemoryResult(result, options, theme)
529
+ renderResult(result: any, options: any, theme: ThemeLike, context: any) {
530
+ return renderAiMemoryWriteResult("ai_memory_vault_edit", result, options, theme, context)
604
531
  },
605
532
  })
606
533
 
@@ -646,36 +573,34 @@ export default function aiMemorySystemExtension(pi: ExtensionAPI): void {
646
573
  .catch(() => ctx.ui.setStatus?.("ai-memory", undefined))
647
574
  })
648
575
 
649
- pi.on?.("before_agent_start", async (event: any) => {
576
+ pi.on?.("before_agent_start", async (event: any, ctx: any) => {
577
+ const patch: { systemPrompt?: string; message?: any } = {}
578
+
650
579
  // Always-loaded durable context (mapped from 00 Kontext) is injected as a
651
580
  // system-prompt chunk so it remains in the cached prefix.
652
- let alwaysBlock: string | undefined
653
581
  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
- }
660
-
661
- const prev = String(event?.systemPrompt ?? "")
662
- if (alwaysBlock && alwaysBlock.trim()) {
663
- const block = alwaysBlock.trim()
664
- return { systemPrompt: `${prev}${prev ? "\n\n" : ""}${block}` }
582
+ const alwaysBlock = buildAlwaysContextMessage(await loadAlwaysContext())?.content?.trim()
583
+ if (alwaysBlock) {
584
+ const prev = String(event?.systemPrompt ?? "")
585
+ patch.systemPrompt = `${prev}${prev ? "\n\n" : ""}${alwaysBlock}`
586
+ }
587
+ } catch (error) {
588
+ // Best-effort, but never silent: a broken API would otherwise drop durable
589
+ // context without anyone noticing.
590
+ reportBackgroundFailure(ctx, "always-context injection", error)
665
591
  }
666
592
 
667
- // Query-specific candidates
668
- if (!shouldInjectAiMemoryCandidates()) return
593
+ // Query-specific candidates (independent of the always-context block).
669
594
  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
595
+ if (shouldInjectAiMemoryCandidates() && query) {
596
+ try {
597
+ const message = buildAiMemoryContextMessage(await loadAiMemoryInjectResults(query))
598
+ if (message) patch.message = message
599
+ } catch (error) {
600
+ reportBackgroundFailure(ctx, "candidate injection", error)
601
+ }
679
602
  }
603
+
604
+ return patch.systemPrompt === undefined && patch.message === undefined ? undefined : patch
680
605
  })
681
606
  }
@@ -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 {