@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.
- package/dist/prism-extensions/integrations/ai-memory-errors.d.ts +13 -0
- package/dist/prism-extensions/integrations/ai-memory-errors.js +54 -0
- package/dist/prism-extensions/integrations/ai-memory-errors.js.map +1 -1
- package/dist/prism-extensions/integrations/ai-memory-http.d.ts +29 -0
- package/dist/prism-extensions/integrations/ai-memory-http.js +102 -0
- package/dist/prism-extensions/integrations/ai-memory-http.js.map +1 -0
- package/dist/prism-extensions/integrations/ai-memory-system.d.ts +3 -0
- package/dist/prism-extensions/integrations/ai-memory-system.js +63 -132
- package/dist/prism-extensions/integrations/ai-memory-system.js.map +1 -1
- package/dist/prism-extensions/integrations/ai-memory-write-preview.d.ts +36 -0
- package/dist/prism-extensions/integrations/ai-memory-write-preview.js +67 -0
- package/dist/prism-extensions/integrations/ai-memory-write-preview.js.map +1 -0
- package/dist/prism-extensions/ui/collapsed-text-rendering.d.ts +4 -2
- package/dist/prism-extensions/ui/collapsed-text-rendering.js +12 -7
- package/dist/prism-extensions/ui/collapsed-text-rendering.js.map +1 -1
- package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js +2365 -2
- package/package.json +4 -3
- package/src/prism-extensions/integrations/ai-memory-errors.ts +59 -0
- package/src/prism-extensions/integrations/ai-memory-http.ts +123 -0
- package/src/prism-extensions/integrations/ai-memory-system.ts +74 -139
- package/src/prism-extensions/integrations/ai-memory-write-preview.ts +83 -0
- 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.
|
|
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
|
-
"
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
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 ??
|
|
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 ??
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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 (
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
|
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 (!
|
|
45
|
-
|
|
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
|
-
|
|
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 {
|