@mohou/runtime-pi 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/package.json +48 -0
- package/src/agent-session.ts +144 -0
- package/src/completion-reasoning.ts +16 -0
- package/src/index.ts +4 -0
- package/src/pi.ts +156 -0
- package/src/register.ts +207 -0
package/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: locked
|
|
3
|
+
updated: 2026-10-01
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @mohou/runtime-pi
|
|
7
|
+
|
|
8
|
+
Role: `provider`.
|
|
9
|
+
|
|
10
|
+
Pi brain. `llm` is one `ModelRuntime.completeSimple` call. `models` is `ModelRuntime.getModels()`. Pi owns the catalog path and the provider API. This package does not read `models.json` and does not send its own HTTP request. `agent` is one in-memory coding-agent session and does not write a session file. Shell injects it. Host does not import this package. Product: [ctx.agent](../../../docs/product/runtime/ctx-agent.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mohou/runtime-pi",
|
|
3
|
+
"version": "1.0.20",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"lib/types",
|
|
15
|
+
"README.md",
|
|
16
|
+
"!**/*.tsbuildinfo"
|
|
17
|
+
],
|
|
18
|
+
"main": "./src/index.ts",
|
|
19
|
+
"types": "./src/index.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./src/index.ts",
|
|
23
|
+
"default": "./src/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@mohou/contract": "^1.0.20",
|
|
30
|
+
"@mohou/runtime-provider": "^1.0.20"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-ai": ">=0.85.0",
|
|
34
|
+
"@earendil-works/pi-coding-agent": ">=0.85.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"@earendil-works/pi-ai": {
|
|
38
|
+
"optional": true
|
|
39
|
+
},
|
|
40
|
+
"@earendil-works/pi-coding-agent": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@earendil-works/pi-ai": "0.85.1",
|
|
46
|
+
"@earendil-works/pi-coding-agent": "0.85.1"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ModelRuntime } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { ProviderError, type RuntimeAgentOptions, type RuntimeProviderConfig } from '@mohou/runtime-provider'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One Pi coding-agent run. The session is in memory. The conversation is not written.
|
|
6
|
+
* The loader uses Pi's agent directory. No tool allowlist is passed.
|
|
7
|
+
* @param config - configured provider and model. Both absent leaves Pi's own default.
|
|
8
|
+
* @param goal - the user turn
|
|
9
|
+
* @param call - cwd, system text, model override, abort, and the observer
|
|
10
|
+
* @returns the last assistant text
|
|
11
|
+
* @throws ProviderError `unknown-model` when the provider/model pair is missing or unknown.
|
|
12
|
+
* `empty-completion` when the run ends with no text. `cancelled` when the signal aborts.
|
|
13
|
+
* `provider-unhealthy` when the session fails.
|
|
14
|
+
*/
|
|
15
|
+
export async function runPiAgent(
|
|
16
|
+
config: RuntimeProviderConfig,
|
|
17
|
+
goal: string,
|
|
18
|
+
call: RuntimeAgentOptions | undefined,
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
const { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, getAgentDir } = await import('@earendil-works/pi-coding-agent')
|
|
21
|
+
const agentDir = getAgentDir()
|
|
22
|
+
const cwd = call?.cwd ?? process.cwd()
|
|
23
|
+
observe(call, { type: 'status', status: 'running' })
|
|
24
|
+
const runtime = await ModelRuntime.create({ refreshOnCreate: false }).catch(unhealthy)
|
|
25
|
+
const model = selectedModel(runtime, config, call) ?? piDefaultModel(runtime, SettingsManager.create(cwd, agentDir))
|
|
26
|
+
const loader = new DefaultResourceLoader({
|
|
27
|
+
cwd,
|
|
28
|
+
agentDir,
|
|
29
|
+
...call?.system === undefined ? {} : { appendSystemPrompt: [call.system] },
|
|
30
|
+
})
|
|
31
|
+
await loader.reload().catch(unhealthy)
|
|
32
|
+
const { session } = await createAgentSession({
|
|
33
|
+
cwd,
|
|
34
|
+
agentDir,
|
|
35
|
+
modelRuntime: runtime,
|
|
36
|
+
resourceLoader: loader,
|
|
37
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
38
|
+
...model === undefined ? {} : { model },
|
|
39
|
+
}).catch(unhealthy)
|
|
40
|
+
let turn = 0
|
|
41
|
+
const unsubscribe = session.subscribe((event) => {
|
|
42
|
+
if (event.type === 'turn_start') {
|
|
43
|
+
turn += 1
|
|
44
|
+
observe(call, { type: 'turn', phase: 'start', turn })
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
if (event.type === 'turn_end') {
|
|
48
|
+
observe(call, {
|
|
49
|
+
type: 'turn',
|
|
50
|
+
phase: 'end',
|
|
51
|
+
turn,
|
|
52
|
+
reason: { kind: call?.signal?.aborted ? 'aborted' : 'completed' },
|
|
53
|
+
})
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
|
57
|
+
observe(call, { type: 'text-delta', text: event.assistantMessageEvent.delta })
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
if (event.type === 'tool_execution_start') {
|
|
61
|
+
observe(call, { type: 'tool', phase: 'start', name: event.toolName, args: event.args })
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (event.type === 'tool_execution_end') {
|
|
65
|
+
observe(call, { type: 'tool', phase: 'end', name: event.toolName, result: event.result })
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
const abort = (): void => {
|
|
69
|
+
void session.abort().catch(() => {
|
|
70
|
+
// Abort is reported through the signal, not this rejection.
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
call?.signal?.addEventListener('abort', abort, { once: true })
|
|
74
|
+
try {
|
|
75
|
+
await session.prompt(goal)
|
|
76
|
+
if (call?.signal?.aborted) throw new ProviderError('cancelled', 'cancelled')
|
|
77
|
+
const text = assistantText(session.messages)
|
|
78
|
+
if (text.length === 0) throw new ProviderError('empty-completion', 'agent returned an empty completion')
|
|
79
|
+
observe(call, { type: 'done', text })
|
|
80
|
+
return text
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof ProviderError) throw error
|
|
83
|
+
if (call?.signal?.aborted) throw new ProviderError('cancelled', 'cancelled', { cause: error })
|
|
84
|
+
throw new ProviderError('provider-unhealthy', 'pi agent failed', { cause: error })
|
|
85
|
+
} finally {
|
|
86
|
+
call?.signal?.removeEventListener('abort', abort)
|
|
87
|
+
unsubscribe()
|
|
88
|
+
session.dispose()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function unhealthy(error: unknown): never {
|
|
93
|
+
throw new ProviderError('provider-unhealthy', 'pi agent failed', { cause: error })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function selectedModel(
|
|
97
|
+
runtime: Pick<ModelRuntime, 'getModel'>,
|
|
98
|
+
config: RuntimeProviderConfig,
|
|
99
|
+
call: { readonly provider?: string; readonly model?: string } | undefined,
|
|
100
|
+
): ReturnType<ModelRuntime['getModel']> {
|
|
101
|
+
const provider = call?.provider ?? config.provider
|
|
102
|
+
const model = call?.model ?? config.model
|
|
103
|
+
if (provider === undefined && model === undefined) return undefined
|
|
104
|
+
if (provider === undefined || model === undefined) {
|
|
105
|
+
throw new ProviderError('unknown-model', 'pi model selection is incomplete')
|
|
106
|
+
}
|
|
107
|
+
const found = runtime.getModel(provider, model)
|
|
108
|
+
if (found === undefined) throw new ProviderError('unknown-model', `unknown model: ${provider}/${model}`)
|
|
109
|
+
return found
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function piDefaultModel(
|
|
113
|
+
runtime: Pick<ModelRuntime, 'getModel'>,
|
|
114
|
+
settings: { getDefaultProvider(): string | undefined; getDefaultModel(): string | undefined },
|
|
115
|
+
): ReturnType<ModelRuntime['getModel']> {
|
|
116
|
+
const provider = settings.getDefaultProvider()
|
|
117
|
+
const model = settings.getDefaultModel()
|
|
118
|
+
if (provider === undefined || model === undefined) return undefined
|
|
119
|
+
const found = runtime.getModel(provider, model)
|
|
120
|
+
if (found === undefined) throw new ProviderError('unknown-model', `unknown model: ${provider}/${model}`)
|
|
121
|
+
return found
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function assistantText(messages: readonly { readonly role: string; readonly content?: unknown }[]): string {
|
|
125
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
126
|
+
const message = messages[index]
|
|
127
|
+
if (message === undefined || message.role !== 'assistant' || !Array.isArray(message.content)) continue
|
|
128
|
+
const text = message.content.flatMap((part) => {
|
|
129
|
+
if (typeof part !== 'object' || part === null) return []
|
|
130
|
+
const record = part as { type?: unknown; text?: unknown }
|
|
131
|
+
return record.type === 'text' && typeof record.text === 'string' ? [record.text] : []
|
|
132
|
+
}).join('').trim()
|
|
133
|
+
if (text.length > 0) return text
|
|
134
|
+
}
|
|
135
|
+
return ''
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function observe(options: RuntimeAgentOptions | undefined, event: Parameters<NonNullable<RuntimeAgentOptions['onEvent']>>[0]): void {
|
|
139
|
+
try {
|
|
140
|
+
options?.onEvent?.(event)
|
|
141
|
+
} catch {
|
|
142
|
+
// A throwing observer does not abort the run.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Api, Model, ThinkingLevel } from '@earendil-works/pi-ai'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thinking level for one completion.
|
|
5
|
+
* Loads `pi-ai` only when a completion runs (optional peer).
|
|
6
|
+
* `clampThinkingLevel(model, 'off')` is Pi's off for this model.
|
|
7
|
+
* `off` is omitted: Pi sends `thinkingLevelMap.off` when that token exists, otherwise the call keeps the server default.
|
|
8
|
+
* @param model - the resolved catalog model
|
|
9
|
+
*/
|
|
10
|
+
export async function completionReasoning(model: Model<Api>): Promise<ThinkingLevel | undefined> {
|
|
11
|
+
if (!model.reasoning) return undefined
|
|
12
|
+
const { clampThinkingLevel } = await import('@earendil-works/pi-ai')
|
|
13
|
+
const level = clampThinkingLevel(model, 'off')
|
|
14
|
+
if (level === 'off') return undefined
|
|
15
|
+
return level
|
|
16
|
+
}
|
package/src/index.ts
ADDED
package/src/pi.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { AppLlmEvent } from '@mohou/contract'
|
|
2
|
+
import type { RuntimeLlmOptions } from '@mohou/runtime-provider'
|
|
3
|
+
import { ProviderError, type ModelListing, type RuntimeProvider, type RuntimeProviderConfig } from '@mohou/runtime-provider'
|
|
4
|
+
|
|
5
|
+
import { piDefaultModel, runPiAgent, selectedModel } from './agent-session.ts'
|
|
6
|
+
import { completionReasoning } from './completion-reasoning.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* True when Node can resolve the optional Pi packages (peer / environment).
|
|
10
|
+
* Does not scan install paths — ordinary package resolution only.
|
|
11
|
+
*/
|
|
12
|
+
export async function probePiRuntime(): Promise<boolean> {
|
|
13
|
+
try {
|
|
14
|
+
await import('@earendil-works/pi-coding-agent')
|
|
15
|
+
await import('@earendil-works/pi-ai')
|
|
16
|
+
return true
|
|
17
|
+
} catch {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pi catalog brain. Pi owns the catalog path and the provider API.
|
|
24
|
+
* `llm` calls `ModelRuntime.completeSimple`. `models` calls `getModels`.
|
|
25
|
+
* Requires optional peers `@earendil-works/pi-coding-agent` and `pi-ai` at call time.
|
|
26
|
+
*/
|
|
27
|
+
export function createPiProvider(): RuntimeProvider {
|
|
28
|
+
let running = false
|
|
29
|
+
let config: RuntimeProviderConfig = {}
|
|
30
|
+
return {
|
|
31
|
+
id: 'pi',
|
|
32
|
+
label: 'Pi',
|
|
33
|
+
configure(next) {
|
|
34
|
+
config = next
|
|
35
|
+
},
|
|
36
|
+
start() {
|
|
37
|
+
running = true
|
|
38
|
+
return Promise.resolve()
|
|
39
|
+
},
|
|
40
|
+
stop() {
|
|
41
|
+
running = false
|
|
42
|
+
return Promise.resolve()
|
|
43
|
+
},
|
|
44
|
+
healthy() {
|
|
45
|
+
return running
|
|
46
|
+
},
|
|
47
|
+
async models() {
|
|
48
|
+
const runtime = await openRuntime().catch(() => undefined)
|
|
49
|
+
return runtime === undefined ? [] : listings(runtime.getModels())
|
|
50
|
+
},
|
|
51
|
+
async llm(prompt, call) {
|
|
52
|
+
return complete(config, running, prompt, call)
|
|
53
|
+
},
|
|
54
|
+
async agent(goal, call) {
|
|
55
|
+
const failed = notRunning(running, call?.signal)
|
|
56
|
+
if (failed) return failed
|
|
57
|
+
if (goal.length === 0) throw new ProviderError('empty-completion', 'agent returned an empty completion')
|
|
58
|
+
return runPiAgent(config, goal, call)
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function openRuntime() {
|
|
64
|
+
const { ModelRuntime } = await import('@earendil-works/pi-coding-agent')
|
|
65
|
+
return ModelRuntime.create({ refreshOnCreate: false })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function complete(
|
|
69
|
+
config: RuntimeProviderConfig,
|
|
70
|
+
running: boolean,
|
|
71
|
+
prompt: string,
|
|
72
|
+
call: RuntimeLlmOptions | undefined,
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
const failed = notRunning(running, call?.signal)
|
|
75
|
+
if (failed) return failed
|
|
76
|
+
if (prompt.length === 0) {
|
|
77
|
+
throw new ProviderError('empty-completion', 'llm returned an empty completion')
|
|
78
|
+
}
|
|
79
|
+
const { SettingsManager, getAgentDir } = await import('@earendil-works/pi-coding-agent')
|
|
80
|
+
const runtime = await openRuntime().catch((error: unknown) => {
|
|
81
|
+
throw new ProviderError('provider-unhealthy', 'pi completion failed', { cause: error })
|
|
82
|
+
})
|
|
83
|
+
const model = selectedModel(runtime, config, call) ?? piDefaultModel(runtime, SettingsManager.create(process.cwd(), getAgentDir()))
|
|
84
|
+
if (model === undefined) throw new ProviderError('unknown-model', 'pi model selection is incomplete')
|
|
85
|
+
const reasoning = await completionReasoning(model)
|
|
86
|
+
const context = {
|
|
87
|
+
messages: [{ role: 'user' as const, content: prompt, timestamp: Date.now() }],
|
|
88
|
+
...call?.system === undefined ? {} : { systemPrompt: call.system },
|
|
89
|
+
}
|
|
90
|
+
const request = {
|
|
91
|
+
...reasoning === undefined ? {} : { reasoning },
|
|
92
|
+
...call?.maxTokens === undefined ? {} : { maxTokens: call.maxTokens },
|
|
93
|
+
...call?.signal === undefined ? {} : { signal: call.signal },
|
|
94
|
+
}
|
|
95
|
+
const message = await (call?.stream === true
|
|
96
|
+
? readStream(runtime.streamSimple(model, context, request), call)
|
|
97
|
+
: runtime.completeSimple(model, context, request)
|
|
98
|
+
).catch((error: unknown) => {
|
|
99
|
+
if (call?.signal?.aborted) throw new ProviderError('cancelled', 'cancelled', { cause: error })
|
|
100
|
+
throw new ProviderError('provider-unhealthy', 'pi completion failed', { cause: error })
|
|
101
|
+
})
|
|
102
|
+
if (call?.signal?.aborted || message.stopReason === 'aborted') throw new ProviderError('cancelled', 'cancelled')
|
|
103
|
+
if (message.stopReason === 'error') throw new ProviderError('provider-unhealthy', 'pi completion failed')
|
|
104
|
+
const text = messageText(message.content)
|
|
105
|
+
if (text.length === 0) throw new ProviderError('empty-completion', 'llm returned an empty completion')
|
|
106
|
+
return text
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function readStream(
|
|
110
|
+
stream: AsyncIterable<{ type: string; delta?: string }> & { result: () => Promise<{ stopReason: string; content: unknown }> },
|
|
111
|
+
call: RuntimeLlmOptions,
|
|
112
|
+
): Promise<{ stopReason: string; content: unknown }> {
|
|
113
|
+
observeLlm(call, { type: 'status', status: 'running' })
|
|
114
|
+
for await (const event of stream) {
|
|
115
|
+
if (event.type === 'text_delta' && typeof event.delta === 'string' && event.delta.length > 0) {
|
|
116
|
+
observeLlm(call, { type: 'text-delta', text: event.delta })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const message = await stream.result()
|
|
120
|
+
const text = messageText(message.content)
|
|
121
|
+
if (text.length > 0) observeLlm(call, { type: 'done', text })
|
|
122
|
+
return message
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function observeLlm(call: RuntimeLlmOptions, event: AppLlmEvent): void {
|
|
126
|
+
try {
|
|
127
|
+
call.onEvent?.(event)
|
|
128
|
+
} catch {
|
|
129
|
+
// A throwing observer does not abort the completion.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function listings(models: readonly { readonly provider: string; readonly id: string }[]): ModelListing[] {
|
|
134
|
+
const groups = new Map<string, string[]>()
|
|
135
|
+
for (const model of models) {
|
|
136
|
+
const list = groups.get(model.provider)
|
|
137
|
+
if (list === undefined) groups.set(model.provider, [model.id])
|
|
138
|
+
else list.push(model.id)
|
|
139
|
+
}
|
|
140
|
+
return [...groups].map(([provider, names]) => ({ provider, models: names }))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function messageText(content: unknown): string {
|
|
144
|
+
if (!Array.isArray(content)) return ''
|
|
145
|
+
return content.flatMap((part) => {
|
|
146
|
+
if (typeof part !== 'object' || part === null) return []
|
|
147
|
+
const record = part as { type?: unknown; text?: unknown }
|
|
148
|
+
return record.type === 'text' && typeof record.text === 'string' ? [record.text] : []
|
|
149
|
+
}).join('').trim()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function notRunning(running: boolean, signal: AbortSignal | undefined): Promise<never> | undefined {
|
|
153
|
+
if (signal?.aborted) return Promise.reject(new ProviderError('cancelled', 'cancelled'))
|
|
154
|
+
if (!running) return Promise.reject(new ProviderError('provider-unhealthy', 'runtime provider is not started'))
|
|
155
|
+
return undefined
|
|
156
|
+
}
|
package/src/register.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
import { ProviderError, type ProviderRegistry, type RuntimeProvider } from '@mohou/runtime-provider'
|
|
8
|
+
|
|
9
|
+
import { createPiProvider } from './pi.ts'
|
|
10
|
+
|
|
11
|
+
const peers = ['@earendil-works/pi-coding-agent', '@earendil-works/pi-ai'] as const
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The three things one load does, injectable so a test can watch their order and the retry.
|
|
15
|
+
* `present` answers from the filesystem on purpose: see {@link createPiLoad}.
|
|
16
|
+
*/
|
|
17
|
+
export interface PiLoadSteps {
|
|
18
|
+
present(): boolean
|
|
19
|
+
link(): void | Promise<void>
|
|
20
|
+
load(): Promise<void>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PiLoad {
|
|
24
|
+
/** One attempt, shared with any concurrent caller. A failure is not remembered. */
|
|
25
|
+
ensure(): Promise<boolean>
|
|
26
|
+
/** Whether the last finished attempt loaded both peers. */
|
|
27
|
+
loaded(): boolean
|
|
28
|
+
/** Why the last finished attempt failed, for the message a caller shows. */
|
|
29
|
+
failure(): string | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One loader for the Pi peers.
|
|
34
|
+
*
|
|
35
|
+
* The first step is a filesystem question, never a module-loader one. Node remembers a failed
|
|
36
|
+
* resolution for the rest of the process, so asking the loader whether `@earendil-works/…` resolves
|
|
37
|
+
* and only then creating the link poisons every later attempt in that process: the host would repair
|
|
38
|
+
* its own prefix and still report Pi as unavailable until the next restart. Repair first, then the
|
|
39
|
+
* one loader call an attempt makes.
|
|
40
|
+
* @param steps - presence check, repair, and the load itself
|
|
41
|
+
*/
|
|
42
|
+
export function createPiLoad(steps: PiLoadSteps): PiLoad {
|
|
43
|
+
let loading: Promise<boolean> | undefined
|
|
44
|
+
let loaded = false
|
|
45
|
+
let failure: string | undefined
|
|
46
|
+
|
|
47
|
+
async function attempt(): Promise<boolean> {
|
|
48
|
+
try {
|
|
49
|
+
// The repair is inside the guard too: a prefix the user cannot write must leave the id
|
|
50
|
+
// registered and unhealthy, not reject the promise every caller awaits.
|
|
51
|
+
if (!steps.present()) await steps.link()
|
|
52
|
+
await steps.load()
|
|
53
|
+
failure = undefined
|
|
54
|
+
return true
|
|
55
|
+
} catch (error) {
|
|
56
|
+
failure = error instanceof Error && error.message.length > 0 ? error.message : 'the peers did not load'
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
ensure() {
|
|
63
|
+
loading ??= attempt().then((ok) => {
|
|
64
|
+
loaded = ok
|
|
65
|
+
// A failed attempt is forgotten, so the next caller retries instead of inheriting it.
|
|
66
|
+
if (!ok) loading = undefined
|
|
67
|
+
return ok
|
|
68
|
+
})
|
|
69
|
+
return loading
|
|
70
|
+
},
|
|
71
|
+
loaded: () => loaded,
|
|
72
|
+
failure: () => failure,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The real steps: the prefix's own `node_modules`, this machine's Pi installs, and the two imports. */
|
|
77
|
+
function realSteps(): PiLoadSteps {
|
|
78
|
+
const prefix = installRoot()
|
|
79
|
+
const roots = () => peerRoots(process.execPath, homedir(), process.env)
|
|
80
|
+
return {
|
|
81
|
+
present: () => peersPresent(prefix),
|
|
82
|
+
async link() {
|
|
83
|
+
linkPiPeers(prefix, roots())
|
|
84
|
+
if (peersPresent(prefix)) return
|
|
85
|
+
const global = await npmRootGlobal(process.env)
|
|
86
|
+
if (global !== undefined) linkPiPeers(prefix, [global])
|
|
87
|
+
},
|
|
88
|
+
async load() {
|
|
89
|
+
await import('@earendil-works/pi-coding-agent')
|
|
90
|
+
await import('@earendil-works/pi-ai')
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const piLoad = createPiLoad(realSteps())
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Register Pi and return immediately. Linking and loading run after this call.
|
|
99
|
+
* A failed load leaves the id registered and unhealthy. It does not fail boot, and a later
|
|
100
|
+
* caller retries it.
|
|
101
|
+
*/
|
|
102
|
+
export function registerPiRuntime(registry: ProviderRegistry): RuntimeProvider {
|
|
103
|
+
void piLoad.ensure()
|
|
104
|
+
const inner = createPiProvider()
|
|
105
|
+
const provider: RuntimeProvider = {
|
|
106
|
+
id: inner.id,
|
|
107
|
+
...inner.label === undefined ? {} : { label: inner.label },
|
|
108
|
+
configure: (config) => { inner.configure?.(config) },
|
|
109
|
+
start: () => inner.start(),
|
|
110
|
+
stop: () => inner.stop(),
|
|
111
|
+
healthy: () => inner.healthy() && piLoad.loaded(),
|
|
112
|
+
models: () => inner.models?.() ?? Promise.resolve([]),
|
|
113
|
+
async llm(prompt, options) {
|
|
114
|
+
if (!await piLoad.ensure()) throw new ProviderError('provider-unhealthy', unavailable(piLoad.failure()))
|
|
115
|
+
return inner.llm(prompt, options)
|
|
116
|
+
},
|
|
117
|
+
async agent(goal, options) {
|
|
118
|
+
if (!await piLoad.ensure()) throw new ProviderError('provider-unhealthy', unavailable(piLoad.failure()))
|
|
119
|
+
return inner.agent(goal, options)
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
registry.register(provider)
|
|
123
|
+
return provider
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function ensurePiLoaded(): Promise<boolean> {
|
|
127
|
+
return piLoad.ensure()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function unavailable(reason: string | undefined): string {
|
|
131
|
+
return reason === undefined ? 'pi is not available' : `pi is not available: ${reason}`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether both peers are reachable through the prefix's `node_modules`. Filesystem only: this runs
|
|
136
|
+
* before the repair, and a loader probe here is what {@link createPiLoad} exists to avoid.
|
|
137
|
+
*/
|
|
138
|
+
function peersPresent(prefix: string): boolean {
|
|
139
|
+
return peers.every(peer => existsSync(path.join(prefix, 'node_modules', ...peer.split('/'))))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Prefix that contains this package's `node_modules`. */
|
|
143
|
+
export function installRoot(): string {
|
|
144
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function peerRoots(node: string, home: string, env: NodeJS.ProcessEnv): string[] {
|
|
148
|
+
const nodeDir = path.dirname(node)
|
|
149
|
+
const prefix = path.dirname(nodeDir)
|
|
150
|
+
const roots = [
|
|
151
|
+
path.join(prefix, 'lib', 'node_modules'),
|
|
152
|
+
path.join(nodeDir, 'node_modules'),
|
|
153
|
+
path.join(nodeDir, 'lib', 'node_modules'),
|
|
154
|
+
path.join(home, 'AppData', 'Roaming', 'npm', 'node_modules'),
|
|
155
|
+
]
|
|
156
|
+
if (typeof env.APPDATA === 'string' && env.APPDATA.length > 0) {
|
|
157
|
+
const appData = path.join(env.APPDATA, 'npm', 'node_modules')
|
|
158
|
+
if (!roots.includes(appData)) roots.push(appData)
|
|
159
|
+
}
|
|
160
|
+
return roots
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function piPackage(root: string, pkg: string): string | undefined {
|
|
164
|
+
const direct = path.join(root, '@earendil-works', pkg)
|
|
165
|
+
if (existsSync(direct)) return direct
|
|
166
|
+
const nested = path.join(root, '@earendil-works', 'pi-coding-agent', 'node_modules', '@earendil-works', pkg)
|
|
167
|
+
return existsSync(nested) ? nested : undefined
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Link both peers into `prefix/node_modules`. A missing package is not an error. */
|
|
171
|
+
export function linkPiPeers(prefix: string, roots: readonly string[]): boolean {
|
|
172
|
+
const destRoot = path.join(prefix, 'node_modules', '@earendil-works')
|
|
173
|
+
mkdirSync(destRoot, { recursive: true })
|
|
174
|
+
let linked = false
|
|
175
|
+
for (const pkg of ['pi-coding-agent', 'pi-ai'] as const) {
|
|
176
|
+
const src = roots.map(root => piPackage(root, pkg)).find(item => item !== undefined)
|
|
177
|
+
if (src === undefined) continue
|
|
178
|
+
placeLink(path.join(destRoot, pkg), src)
|
|
179
|
+
linked = true
|
|
180
|
+
}
|
|
181
|
+
return linked
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function npmRootGlobal(env: NodeJS.ProcessEnv): Promise<string | undefined> {
|
|
185
|
+
const npm = path.join(path.dirname(process.execPath), process.platform === 'win32' ? 'npm.cmd' : 'npm')
|
|
186
|
+
if (!existsSync(npm)) return Promise.resolve(undefined)
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
execFile(npm, ['root', '-g'], { env, timeout: 5_000 }, (error, stdout) => {
|
|
189
|
+
if (error) {
|
|
190
|
+
resolve(undefined)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
resolve(stdout.split(/\r?\n/).find(line => line.length > 0))
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function placeLink(dest: string, src: string): void {
|
|
199
|
+
try {
|
|
200
|
+
const stat = lstatSync(dest)
|
|
201
|
+
if (!stat.isSymbolicLink()) return
|
|
202
|
+
rmSync(dest)
|
|
203
|
+
} catch {
|
|
204
|
+
// missing dest
|
|
205
|
+
}
|
|
206
|
+
symlinkSync(src, dest, process.platform === 'win32' ? 'junction' : 'dir')
|
|
207
|
+
}
|