typeclaw 0.36.8 → 0.37.1
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 +3 -3
- package/package.json +3 -2
- package/src/agent/index.ts +31 -11
- package/src/agent/live-sessions.ts +12 -0
- package/src/agent/model-fallback.ts +17 -15
- package/src/agent/model-overrides.ts +2 -2
- package/src/agent/session-meta.ts +10 -0
- package/src/agent/subagents.ts +30 -3
- package/src/agent/system-prompt.ts +9 -3
- package/src/agent/todo/continuation-policy.ts +6 -3
- package/src/agent/todo/continuation-wiring.ts +4 -2
- package/src/agent/todo/continuation.ts +3 -3
- package/src/agent/tools/todo/index.ts +27 -4
- package/src/bundled-plugins/agent-browser/index.ts +33 -108
- package/src/bundled-plugins/agent-browser/shim.ts +3 -94
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +8 -33
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +2 -2
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +7 -1
- package/src/bundled-plugins/memory/README.md +80 -23
- package/src/bundled-plugins/memory/append-tool.ts +74 -53
- package/src/bundled-plugins/memory/citation-superset.ts +4 -0
- package/src/bundled-plugins/memory/citations.ts +54 -0
- package/src/bundled-plugins/memory/dreaming-metrics.ts +30 -0
- package/src/bundled-plugins/memory/dreaming.ts +444 -21
- package/src/bundled-plugins/memory/index.ts +544 -400
- package/src/bundled-plugins/memory/load-memory.ts +87 -10
- package/src/bundled-plugins/memory/load-shards.ts +48 -22
- package/src/bundled-plugins/memory/memory-logger.ts +95 -106
- package/src/bundled-plugins/memory/memory-retrieval.ts +3 -3
- package/src/bundled-plugins/memory/parent-link.ts +33 -0
- package/src/bundled-plugins/memory/paths.ts +12 -0
- package/src/bundled-plugins/memory/references/frontmatter.ts +197 -0
- package/src/bundled-plugins/memory/references/load-references.ts +212 -0
- package/src/bundled-plugins/memory/references/store-reference-tool.ts +59 -0
- package/src/bundled-plugins/memory/search-tool.ts +282 -45
- package/src/bundled-plugins/memory/stream-events.ts +1 -0
- package/src/bundled-plugins/memory/stream-io.ts +28 -3
- package/src/bundled-plugins/memory/turn-dedup.ts +40 -0
- package/src/bundled-plugins/memory/vector/cache-write.ts +19 -0
- package/src/bundled-plugins/memory/vector/config.ts +28 -0
- package/src/bundled-plugins/memory/vector/doctor.ts +124 -0
- package/src/bundled-plugins/memory/vector/embedder.ts +246 -0
- package/src/bundled-plugins/memory/vector/hybrid.ts +439 -0
- package/src/bundled-plugins/memory/vector/index-on-write.ts +34 -0
- package/src/bundled-plugins/memory/vector/inspect.ts +111 -0
- package/src/bundled-plugins/memory/vector/passages.ts +125 -0
- package/src/bundled-plugins/memory/vector/reference-index-on-write.ts +50 -0
- package/src/bundled-plugins/memory/vector/relevance-gate.ts +93 -0
- package/src/bundled-plugins/memory/vector/startup.ts +71 -0
- package/src/bundled-plugins/memory/vector/store.ts +203 -0
- package/src/bundled-plugins/memory/vector/truncation.ts +124 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
- package/src/channels/router.ts +239 -40
- package/src/cli/incomplete-init.ts +57 -0
- package/src/cli/init.ts +166 -18
- package/src/cli/inspect.ts +11 -5
- package/src/cli/model.ts +115 -36
- package/src/cli/provider.ts +5 -3
- package/src/cli/restart.ts +24 -0
- package/src/cli/start.ts +24 -0
- package/src/cli/tunnel.ts +53 -8
- package/src/config/config.ts +110 -19
- package/src/config/index.ts +5 -1
- package/src/config/models-mutation.ts +29 -11
- package/src/config/providers-mutation.ts +2 -2
- package/src/config/providers.ts +146 -12
- package/src/container/shared.ts +9 -0
- package/src/container/start.ts +87 -4
- package/src/cron/consumer.ts +13 -7
- package/src/hostd/models.ts +64 -0
- package/src/hostd/paths.ts +6 -0
- package/src/hostd/portbroker-manager.ts +2 -2
- package/src/init/checkpoint.ts +201 -0
- package/src/init/dockerfile.ts +121 -34
- package/src/init/gitignore.ts +7 -7
- package/src/init/index.ts +41 -9
- package/src/init/models-dev.ts +96 -21
- package/src/init/oauth-login.ts +3 -3
- package/src/init/progress.ts +29 -0
- package/src/init/validate-api-key.ts +4 -0
- package/src/inspect/index.ts +13 -6
- package/src/inspect/item-list.ts +11 -2
- package/src/inspect/live-list.ts +65 -0
- package/src/inspect/open-item.ts +22 -1
- package/src/inspect/session-list.ts +29 -0
- package/src/models/embedding-model.ts +114 -0
- package/src/models/transformers-version.ts +55 -0
- package/src/plugin/types.ts +3 -0
- package/src/portbroker/container-server.ts +23 -0
- package/src/portbroker/forward-request-bus.ts +35 -0
- package/src/portbroker/forward-result-bus.ts +2 -3
- package/src/portbroker/hostd-client.ts +182 -36
- package/src/portbroker/index.ts +6 -1
- package/src/portbroker/protocol.ts +9 -2
- package/src/run/channel-session-factory.ts +11 -1
- package/src/run/index.ts +65 -8
- package/src/server/command-runner.ts +24 -1
- package/src/server/index.ts +42 -8
- package/src/shared/index.ts +2 -0
- package/src/shared/protocol.ts +31 -0
- package/src/skills/typeclaw-channels/SKILL.md +4 -4
- package/src/skills/typeclaw-config/SKILL.md +2 -2
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/skills/typeclaw-permissions/SKILL.md +3 -3
- package/src/skills/typeclaw-skills/SKILL.md +1 -1
- package/src/skills/typeclaw-tunnels/SKILL.md +22 -1
- package/src/tunnels/providers/cloudflare-quick.ts +65 -7
- package/src/tunnels/upstream-probe.ts +25 -0
- package/typeclaw.schema.json +156 -67
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +0 -170
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +0 -421
- package/src/portbroker/bind-with-forward.ts +0 -102
package/src/init/models-dev.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { CustomModelMeta } from '@/config'
|
|
2
|
+
import {
|
|
3
|
+
KNOWN_PROVIDERS,
|
|
4
|
+
isKnownModelRef,
|
|
5
|
+
isModelRef,
|
|
6
|
+
listKnownModelRefs,
|
|
7
|
+
providerForModelRef,
|
|
8
|
+
type KnownProviderId,
|
|
9
|
+
type ModelRef,
|
|
10
|
+
} from '@/config/providers'
|
|
2
11
|
|
|
3
12
|
const MODELS_DEV_URL = 'https://models.dev/api.json'
|
|
4
13
|
const REQUEST_TIMEOUT_MS = 10_000
|
|
@@ -23,16 +32,24 @@ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
|
|
|
23
32
|
xai: 'xai',
|
|
24
33
|
minimax: 'minimax',
|
|
25
34
|
deepseek: 'deepseek',
|
|
35
|
+
moonshot: 'moonshot',
|
|
36
|
+
// moonshot-coding (Kimi Code subscription) is a billing surface, not a
|
|
37
|
+
// separate model catalog. models.dev tracks the underlying Kimi model
|
|
38
|
+
// metadata under `moonshot`, so we route lookups there; the curated
|
|
39
|
+
// `kimi-for-coding` alias is surfaced regardless of upstream membership.
|
|
40
|
+
'moonshot-coding': 'moonshot',
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
export type ModelOption = {
|
|
29
|
-
ref:
|
|
44
|
+
ref: ModelRef | string
|
|
30
45
|
providerId: KnownProviderId
|
|
31
46
|
providerName: string
|
|
32
47
|
modelId: string
|
|
33
48
|
modelName: string
|
|
34
49
|
reasoning: boolean
|
|
35
50
|
contextWindow: number | null
|
|
51
|
+
maxTokens?: number | null
|
|
52
|
+
cost?: ModelOptionCost | null
|
|
36
53
|
curated: boolean
|
|
37
54
|
// True iff the model accepts image input. Sourced from the curated
|
|
38
55
|
// `Model.input` array (which is the source of truth — pi-ai consumes it
|
|
@@ -43,6 +60,13 @@ export type ModelOption = {
|
|
|
43
60
|
supportsVision: boolean
|
|
44
61
|
}
|
|
45
62
|
|
|
63
|
+
export type ModelOptionCost = {
|
|
64
|
+
input: number
|
|
65
|
+
output: number
|
|
66
|
+
cacheRead: number
|
|
67
|
+
cacheWrite: number
|
|
68
|
+
}
|
|
69
|
+
|
|
46
70
|
type ModelsDevModel = {
|
|
47
71
|
id?: string
|
|
48
72
|
name?: string
|
|
@@ -51,7 +75,15 @@ type ModelsDevModel = {
|
|
|
51
75
|
status?: string
|
|
52
76
|
release_date?: string
|
|
53
77
|
modalities?: { input?: string[]; output?: string[] }
|
|
54
|
-
limit?: { context?: number }
|
|
78
|
+
limit?: { context?: number; output?: number }
|
|
79
|
+
cost?: {
|
|
80
|
+
input?: number
|
|
81
|
+
output?: number
|
|
82
|
+
cacheRead?: number
|
|
83
|
+
cacheWrite?: number
|
|
84
|
+
cache_read?: number
|
|
85
|
+
cache_write?: number
|
|
86
|
+
}
|
|
55
87
|
}
|
|
56
88
|
|
|
57
89
|
type ModelsDevProvider = {
|
|
@@ -99,22 +131,47 @@ export function curatedOptions(): ModelOption[] {
|
|
|
99
131
|
return refs.map((ref) => buildOption(ref, { curated: true }))
|
|
100
132
|
}
|
|
101
133
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
134
|
+
export function customModelMetaFromOption(option: ModelOption): CustomModelMeta | undefined {
|
|
135
|
+
if (isKnownModelRef(option.ref)) return undefined
|
|
136
|
+
if (!isModelRef(option.ref)) return undefined
|
|
137
|
+
return {
|
|
138
|
+
name: option.modelName,
|
|
139
|
+
reasoning: option.reasoning,
|
|
140
|
+
input: option.supportsVision ? ['text', 'image'] : ['text'],
|
|
141
|
+
...(option.contextWindow !== null ? { contextWindow: option.contextWindow } : {}),
|
|
142
|
+
...(option.maxTokens !== undefined && option.maxTokens !== null ? { maxTokens: option.maxTokens } : {}),
|
|
143
|
+
...(option.cost !== undefined && option.cost !== null ? { cost: option.cost } : {}),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// `data` is the parsed models.dev JSON. We keep every curated entry first
|
|
148
|
+
// (including provider-specific aliases models.dev does not list), then append
|
|
149
|
+
// live upstream models whose refs validate against a known TypeClaw provider.
|
|
108
150
|
function mergeWithCurated(data: Record<string, ModelsDevProvider>): ModelOption[] {
|
|
109
151
|
const out: ModelOption[] = []
|
|
152
|
+
const seen = new Set<string>()
|
|
110
153
|
for (const providerId of Object.keys(KNOWN_PROVIDERS) as KnownProviderId[]) {
|
|
111
154
|
const known = KNOWN_PROVIDERS[providerId]
|
|
112
155
|
const upstream = data[PROVIDER_TO_MODELS_DEV[providerId]]
|
|
113
156
|
const upstreamModels = upstream?.models ?? {}
|
|
114
157
|
for (const modelId of Object.keys(known.models)) {
|
|
115
158
|
const upstreamModel = upstreamModels[modelId]
|
|
116
|
-
const ref = `${providerId}/${modelId}`
|
|
159
|
+
const ref = `${providerId}/${modelId}`
|
|
117
160
|
out.push(buildOption(ref, { curated: true, upstream: upstreamModel }))
|
|
161
|
+
seen.add(ref)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const providerId of Object.keys(KNOWN_PROVIDERS) as KnownProviderId[]) {
|
|
166
|
+
const upstream = data[PROVIDER_TO_MODELS_DEV[providerId]]
|
|
167
|
+
const upstreamModels = upstream?.models ?? {}
|
|
168
|
+
for (const [fallbackModelId, upstreamModel] of Object.entries(upstreamModels)) {
|
|
169
|
+
const modelId = upstreamModel.id ?? fallbackModelId
|
|
170
|
+
if (modelId.trim().length === 0) continue
|
|
171
|
+
const ref = `${providerId}/${modelId}`
|
|
172
|
+
if (seen.has(ref) || !isModelRef(ref)) continue
|
|
173
|
+
out.push(buildOption(ref, { curated: isKnownModelRef(ref), upstream: upstreamModel }))
|
|
174
|
+
seen.add(ref)
|
|
118
175
|
}
|
|
119
176
|
}
|
|
120
177
|
return out
|
|
@@ -125,17 +182,23 @@ type BuildOptionOpts = {
|
|
|
125
182
|
upstream?: ModelsDevModel
|
|
126
183
|
}
|
|
127
184
|
|
|
128
|
-
function buildOption(ref:
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
const modelId = ref.slice(slash + 1)
|
|
185
|
+
function buildOption(ref: ModelRef | string, opts: BuildOptionOpts): ModelOption {
|
|
186
|
+
const providerId = providerForModelRef(ref)
|
|
187
|
+
const modelId = ref.slice(providerId.length + 1)
|
|
132
188
|
const provider = KNOWN_PROVIDERS[providerId]
|
|
133
189
|
const curatedModel = (
|
|
134
190
|
provider.models as Record<
|
|
135
191
|
string,
|
|
136
|
-
{
|
|
192
|
+
{
|
|
193
|
+
name: string
|
|
194
|
+
contextWindow?: number
|
|
195
|
+
maxTokens?: number
|
|
196
|
+
reasoning?: boolean
|
|
197
|
+
input?: ReadonlyArray<string>
|
|
198
|
+
}
|
|
137
199
|
>
|
|
138
200
|
)[modelId]
|
|
201
|
+
const input = resolveInput(curatedModel?.input, opts.upstream?.modalities?.input)
|
|
139
202
|
return {
|
|
140
203
|
ref,
|
|
141
204
|
providerId,
|
|
@@ -144,16 +207,28 @@ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
|
|
|
144
207
|
modelName: opts.upstream?.name ?? curatedModel?.name ?? modelId,
|
|
145
208
|
reasoning: opts.upstream?.reasoning ?? curatedModel?.reasoning ?? false,
|
|
146
209
|
contextWindow: opts.upstream?.limit?.context ?? curatedModel?.contextWindow ?? null,
|
|
210
|
+
maxTokens: opts.upstream?.limit?.output ?? curatedModel?.maxTokens ?? null,
|
|
211
|
+
cost: resolveCost(opts.upstream?.cost),
|
|
147
212
|
curated: opts.curated,
|
|
148
|
-
supportsVision:
|
|
213
|
+
supportsVision: input.includes('image'),
|
|
149
214
|
}
|
|
150
215
|
}
|
|
151
216
|
|
|
152
|
-
function
|
|
217
|
+
function resolveInput(
|
|
153
218
|
curatedInput: ReadonlyArray<string> | undefined,
|
|
154
219
|
upstreamInput: ReadonlyArray<string> | undefined,
|
|
155
|
-
):
|
|
156
|
-
if (curatedInput !== undefined) return curatedInput
|
|
157
|
-
if (upstreamInput !== undefined) return upstreamInput
|
|
158
|
-
return
|
|
220
|
+
): string[] {
|
|
221
|
+
if (curatedInput !== undefined) return [...curatedInput]
|
|
222
|
+
if (upstreamInput !== undefined && upstreamInput.length > 0) return [...upstreamInput]
|
|
223
|
+
return ['text']
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function resolveCost(cost: ModelsDevModel['cost']): ModelOptionCost | null {
|
|
227
|
+
if (cost === undefined) return null
|
|
228
|
+
return {
|
|
229
|
+
input: cost.input ?? 0,
|
|
230
|
+
output: cost.output ?? 0,
|
|
231
|
+
cacheRead: cost.cacheRead ?? cost.cache_read ?? 0,
|
|
232
|
+
cacheWrite: cost.cacheWrite ?? cost.cache_write ?? 0,
|
|
233
|
+
}
|
|
159
234
|
}
|
package/src/init/oauth-login.ts
CHANGED
|
@@ -4,14 +4,14 @@ import {
|
|
|
4
4
|
KNOWN_PROVIDERS,
|
|
5
5
|
providerForModelRef,
|
|
6
6
|
supportsOAuth,
|
|
7
|
-
type KnownModelRef,
|
|
8
7
|
type KnownProviderId,
|
|
8
|
+
type ModelRef,
|
|
9
9
|
} from '@/config/providers'
|
|
10
10
|
import { createSecretsStoreForAgent } from '@/secrets'
|
|
11
11
|
|
|
12
12
|
export type OAuthLoginResult = { ok: true } | { ok: false; reason: string }
|
|
13
13
|
|
|
14
|
-
export type OAuthLoginRunner = (options: { cwd: string; model:
|
|
14
|
+
export type OAuthLoginRunner = (options: { cwd: string; model: ModelRef | string }) => Promise<OAuthLoginResult>
|
|
15
15
|
|
|
16
16
|
// Wrap pi-ai's OAuth callbacks so the CLI doesn't have to know about the
|
|
17
17
|
// upstream callback shape. The CLI sees four lifecycle events:
|
|
@@ -76,7 +76,7 @@ export function makeOAuthLoginRunner(callbacks: OAuthCallbacks): OAuthLoginRunne
|
|
|
76
76
|
// params" without spinning up a real secrets store / browser callback server.
|
|
77
77
|
export type FakeOAuthLoginRunnerOptions = {
|
|
78
78
|
result?: OAuthLoginResult
|
|
79
|
-
onCalled?: (options: { cwd: string; model:
|
|
79
|
+
onCalled?: (options: { cwd: string; model: ModelRef | string; providerId: KnownProviderId }) => void
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export function makeFakeOAuthLoginRunner(options: FakeOAuthLoginRunnerOptions = {}): OAuthLoginRunner {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { WizardAnswerCheckpointV1, WizardCheckpointStore } from './checkpoint'
|
|
2
|
+
import { isHatched } from './index'
|
|
3
|
+
|
|
4
|
+
export type InitProgressStatus =
|
|
5
|
+
| { kind: 'none' }
|
|
6
|
+
| { kind: 'incomplete'; checkpoint: WizardAnswerCheckpointV1 }
|
|
7
|
+
| { kind: 'complete-stale-checkpoint'; checkpoint: WizardAnswerCheckpointV1 }
|
|
8
|
+
|
|
9
|
+
export interface DetectInitProgressOptions {
|
|
10
|
+
cwd: string
|
|
11
|
+
checkpointStore: WizardCheckpointStore
|
|
12
|
+
isHatched?: (dir: string) => Promise<boolean>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Single shared predicate for "is this init incomplete?", consumed by both the
|
|
16
|
+
// init resume-prompt and the start/restart launchers so the two never drift.
|
|
17
|
+
//
|
|
18
|
+
// `isHatched` is the completion authority — NOT the presence of node_modules,
|
|
19
|
+
// Dockerfile, or typeclaw.json, which are intermediate artifacts that start can
|
|
20
|
+
// regenerate. A checkpoint that outlives a hatched agent (clear failed after a
|
|
21
|
+
// successful run) is reported as `complete-stale-checkpoint` so callers can
|
|
22
|
+
// opportunistically clean it up instead of falsely blocking a working agent.
|
|
23
|
+
export async function detectInitProgress(options: DetectInitProgressOptions): Promise<InitProgressStatus> {
|
|
24
|
+
const hatchedCheck = options.isHatched ?? isHatched
|
|
25
|
+
const checkpoint = await options.checkpointStore.load(options.cwd)
|
|
26
|
+
if (checkpoint === undefined) return { kind: 'none' }
|
|
27
|
+
if (await hatchedCheck(options.cwd)) return { kind: 'complete-stale-checkpoint', checkpoint }
|
|
28
|
+
return { kind: 'incomplete', checkpoint }
|
|
29
|
+
}
|
|
@@ -10,6 +10,8 @@ const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader:
|
|
|
10
10
|
xai: { url: 'https://api.x.ai/v1/models', authHeader: 'bearer' },
|
|
11
11
|
minimax: { url: 'https://api.minimax.io/v1/models', authHeader: 'bearer' },
|
|
12
12
|
deepseek: { url: 'https://api.deepseek.com/models', authHeader: 'bearer' },
|
|
13
|
+
moonshot: { url: 'https://api.moonshot.ai/v1/models', authHeader: 'bearer' },
|
|
14
|
+
'moonshot-coding': { url: 'https://api.kimi.com/coding/v1/models', authHeader: 'bearer' },
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
// When a base-URL override (ANTHROPIC_BASE_URL / OPENAI_BASE_URL) points at a
|
|
@@ -165,6 +167,8 @@ export const API_KEY_DASHBOARD_URL: Partial<Record<KnownProviderId, string>> = {
|
|
|
165
167
|
xai: 'https://console.x.ai',
|
|
166
168
|
minimax: 'https://platform.minimax.io/user-center/basic-information/interface-key',
|
|
167
169
|
deepseek: 'https://platform.deepseek.com/api_keys',
|
|
170
|
+
moonshot: 'https://platform.moonshot.ai/console/api-keys',
|
|
171
|
+
'moonshot-coding': 'https://www.kimi.com/code/console',
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
// MiniMax sells the same `minimax` provider under two billing surfaces that
|
package/src/inspect/index.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { originLabel, shortSessionId } from './label'
|
|
|
14
14
|
export { renderEvent } from './render'
|
|
15
15
|
export { replayJsonl } from './replay'
|
|
16
16
|
export { streamLive } from './live'
|
|
17
|
+
export { fetchLiveSessions } from './live-list'
|
|
18
|
+
export type { FetchLiveSessionsOptions } from './live-list'
|
|
17
19
|
export { parseDuration, parseFilter } from './types'
|
|
18
20
|
export type { InspectCategory, InspectEvent, InspectFilter } from './types'
|
|
19
21
|
export { runInspectLoop, runViewerLoop } from './loop'
|
|
@@ -219,12 +221,17 @@ export async function streamSessionEvents(opts: StreamSessionEventsOptions): Pro
|
|
|
219
221
|
opts.onEvent(event)
|
|
220
222
|
}
|
|
221
223
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
|
|
224
|
+
// A live-only session (registry-derived, no .jsonl yet) has an empty
|
|
225
|
+
// sessionFile: skip replay and go straight to the live tail. Replaying ''
|
|
226
|
+
// would just emit a spurious "file does not exist" warning.
|
|
227
|
+
if (opts.summary.sessionFile !== '') {
|
|
228
|
+
for await (const event of replayJsonl(
|
|
229
|
+
opts.summary.sessionFile,
|
|
230
|
+
opts.onWarn !== undefined ? { onWarn: opts.onWarn } : {},
|
|
231
|
+
)) {
|
|
232
|
+
if (aborted()) return { escToPicker: true }
|
|
233
|
+
deliver(event)
|
|
234
|
+
}
|
|
228
235
|
}
|
|
229
236
|
opts.onPhase?.({ phase: 'replay-end' })
|
|
230
237
|
|
package/src/inspect/item-list.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { LiveSessionPayload } from '@/shared'
|
|
2
|
+
|
|
1
3
|
import type { ViewerItem } from './item'
|
|
2
|
-
import { listSessions, type ListSessionsOptions, type SessionSummary } from './session-list'
|
|
4
|
+
import { listSessions, type ListSessionsOptions, mergeLiveSessions, type SessionSummary } from './session-list'
|
|
3
5
|
|
|
4
6
|
export type ListViewerItemsOptions = ListSessionsOptions & {
|
|
5
7
|
containerRunning: boolean
|
|
@@ -9,6 +11,9 @@ export type ListViewerItemsOptions = ListSessionsOptions & {
|
|
|
9
11
|
// (most-recent) tui transcript — and any older tui transcript the heuristic
|
|
10
12
|
// would otherwise promote — must NOT be offered as a writable live row.
|
|
11
13
|
allowWritable?: boolean
|
|
14
|
+
// Registry sessions not yet flushed to disk, fetched by the CLI over the
|
|
15
|
+
// /inspect WS. The lib layer stays I/O-free; the caller owns the connection.
|
|
16
|
+
liveSessions?: LiveSessionPayload[]
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
export type ViewerList = {
|
|
@@ -23,7 +28,11 @@ export type ViewerList = {
|
|
|
23
28
|
// sessions are read-only. The `logs` row is appended last (container stdout,
|
|
24
29
|
// available offline) so it sits below the divider in the picker.
|
|
25
30
|
export async function listViewerItems(opts: ListViewerItemsOptions): Promise<ViewerList> {
|
|
26
|
-
const
|
|
31
|
+
const diskSessions = await listSessions(opts)
|
|
32
|
+
const sessions =
|
|
33
|
+
opts.liveSessions !== undefined && opts.liveSessions.length > 0
|
|
34
|
+
? mergeLiveSessions(diskSessions, opts.liveSessions)
|
|
35
|
+
: diskSessions
|
|
27
36
|
const allowWritable = opts.allowWritable !== false
|
|
28
37
|
const writableSessionId = opts.containerRunning && allowWritable ? pickWritableSession(sessions) : null
|
|
29
38
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { InspectClientMessage, InspectServerMessage, LiveSessionPayload } from '@/shared'
|
|
2
|
+
|
|
3
|
+
export type FetchLiveSessionsOptions = {
|
|
4
|
+
url: string
|
|
5
|
+
signal?: AbortSignal
|
|
6
|
+
WebSocketImpl?: typeof WebSocket
|
|
7
|
+
timeoutMs?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 5_000
|
|
11
|
+
|
|
12
|
+
// One-shot query of the container's in-memory session registry over the
|
|
13
|
+
// /inspect WS: open, send list_live, read the single reply, close. Failure
|
|
14
|
+
// (container down, timeout, abort) resolves to [] so the picker degrades to the
|
|
15
|
+
// disk-only listing rather than erroring — the live overlay is best-effort.
|
|
16
|
+
export async function fetchLiveSessions(opts: FetchLiveSessionsOptions): Promise<LiveSessionPayload[]> {
|
|
17
|
+
const WS = opts.WebSocketImpl ?? WebSocket
|
|
18
|
+
if (opts.signal?.aborted === true) return []
|
|
19
|
+
|
|
20
|
+
return new Promise<LiveSessionPayload[]>((resolve) => {
|
|
21
|
+
let settled = false
|
|
22
|
+
const ws = new WS(opts.url)
|
|
23
|
+
|
|
24
|
+
const finish = (result: LiveSessionPayload[]): void => {
|
|
25
|
+
if (settled) return
|
|
26
|
+
settled = true
|
|
27
|
+
clearTimeout(timer)
|
|
28
|
+
try {
|
|
29
|
+
ws.close()
|
|
30
|
+
} catch {
|
|
31
|
+
/* ignore */
|
|
32
|
+
}
|
|
33
|
+
resolve(result)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const timer = setTimeout(() => finish([]), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
37
|
+
|
|
38
|
+
if (opts.signal !== undefined) {
|
|
39
|
+
opts.signal.addEventListener('abort', () => finish([]), { once: true })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
ws.addEventListener('open', () => {
|
|
43
|
+
const req: InspectClientMessage = { type: 'list_live' }
|
|
44
|
+
try {
|
|
45
|
+
ws.send(JSON.stringify(req))
|
|
46
|
+
} catch {
|
|
47
|
+
finish([])
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
ws.addEventListener('message', (e) => {
|
|
52
|
+
let msg: InspectServerMessage
|
|
53
|
+
try {
|
|
54
|
+
msg = JSON.parse(String((e as MessageEvent).data)) as InspectServerMessage
|
|
55
|
+
} catch {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (msg.type === 'live_sessions') finish(msg.sessions)
|
|
59
|
+
else if (msg.type === 'error') finish([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
ws.addEventListener('error', () => finish([]))
|
|
63
|
+
ws.addEventListener('close', () => finish([]))
|
|
64
|
+
})
|
|
65
|
+
}
|
package/src/inspect/open-item.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
1
3
|
import type { LiveSourceFactory, RunInspectResult } from './index'
|
|
2
4
|
import { createTranscriptView, streamInspectTarget } from './index'
|
|
3
5
|
import type { ViewerItem } from './item'
|
|
4
6
|
import { streamLogs } from './logs-item'
|
|
5
7
|
import type { OpenItemContext, OpenItemResult, TailController } from './loop'
|
|
8
|
+
import { resolveSession } from './session-list'
|
|
6
9
|
import { runTuiViewer } from './tui-item'
|
|
7
10
|
import type { InspectFilter } from './types'
|
|
8
11
|
|
|
@@ -28,7 +31,14 @@ export type OpenViewerDeps = {
|
|
|
28
31
|
// would corrupt input). The line/JSON session path and logs run UNDER the tail
|
|
29
32
|
// scope, which owns the raw-mode esc/q/ctrl-c handling.
|
|
30
33
|
export function openViewerItem(deps: OpenViewerDeps) {
|
|
31
|
-
return async (
|
|
34
|
+
return async (rawItem: ViewerItem, ctx: OpenItemContext): Promise<OpenItemResult> => {
|
|
35
|
+
// A live-only row captured `sessionFile: ''` when it was listed. By the time
|
|
36
|
+
// the user opens it the reply may have flushed to disk AND the registry
|
|
37
|
+
// entry may be gone — leaving the live tail broadcast-only and skipping the
|
|
38
|
+
// now-existing transcript. Re-resolve against the sessions dir so a
|
|
39
|
+
// flushed session opens as its real disk summary (replay + live tail).
|
|
40
|
+
const item = await reresolveLiveItem(rawItem, deps.cwd, deps.stderr)
|
|
41
|
+
|
|
32
42
|
if (item.kind === 'tui') {
|
|
33
43
|
const result = await runTuiViewer({
|
|
34
44
|
resolveUrl: deps.resolveTuiUrl,
|
|
@@ -93,6 +103,17 @@ export function openViewerItem(deps: OpenViewerDeps) {
|
|
|
93
103
|
}
|
|
94
104
|
}
|
|
95
105
|
|
|
106
|
+
export async function reresolveLiveItem(
|
|
107
|
+
item: ViewerItem,
|
|
108
|
+
cwd: string,
|
|
109
|
+
onWarn: (line: string) => void,
|
|
110
|
+
): Promise<ViewerItem> {
|
|
111
|
+
if (item.kind === 'logs' || item.summary.live !== true) return item
|
|
112
|
+
const resolved = await resolveSession(join(cwd, 'sessions'), item.summary.sessionId, onWarn)
|
|
113
|
+
if (!resolved.ok) return item
|
|
114
|
+
return { kind: 'session', summary: resolved.summary, writable: false }
|
|
115
|
+
}
|
|
116
|
+
|
|
96
117
|
function toResult(escToPicker: boolean, scope: TailController): RunInspectResult {
|
|
97
118
|
if (scope.intent() === 'exit') return { ok: true, exitCode: 0 }
|
|
98
119
|
if (escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
|
|
@@ -2,6 +2,7 @@ import { readdir, stat } from 'node:fs/promises'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import type { MinimalSessionOrigin } from '@/agent/session-meta'
|
|
5
|
+
import type { LiveSessionPayload } from '@/shared'
|
|
5
6
|
|
|
6
7
|
import { previewForHint } from './preview'
|
|
7
8
|
import { replayJsonl } from './replay'
|
|
@@ -13,6 +14,11 @@ export type SessionSummary = {
|
|
|
13
14
|
mtimeMs: number
|
|
14
15
|
origin: MinimalSessionOrigin | null
|
|
15
16
|
firstPrompt: string | null
|
|
17
|
+
// True only for a registry-derived session with no .jsonl on disk yet (a
|
|
18
|
+
// reply is in flight). Disk sessions leave this undefined. Selecting one tails
|
|
19
|
+
// live-only: streamSessionEvents replays an empty file, then the WS delivers
|
|
20
|
+
// events as they happen.
|
|
21
|
+
live?: boolean
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
export type ListSessionsOptions = {
|
|
@@ -65,6 +71,29 @@ export async function listSessions(opts: ListSessionsOptions): Promise<SessionSu
|
|
|
65
71
|
)
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
// Overlay container-registry sessions onto the disk listing. A live session
|
|
75
|
+
// already flushed to disk (post-reply) is dropped from the overlay — the disk
|
|
76
|
+
// summary wins, carrying its real mtime and prompt preview. Only sessions with
|
|
77
|
+
// no .jsonl yet become synthetic live rows, sorted to the top by registration
|
|
78
|
+
// time so an in-flight reply surfaces above settled history.
|
|
79
|
+
export function mergeLiveSessions(disk: SessionSummary[], live: LiveSessionPayload[]): SessionSummary[] {
|
|
80
|
+
const onDisk = new Set(disk.map((s) => s.sessionId))
|
|
81
|
+
const liveOnly = live
|
|
82
|
+
.filter((l) => !onDisk.has(l.sessionId))
|
|
83
|
+
.map(
|
|
84
|
+
(l): SessionSummary => ({
|
|
85
|
+
sessionId: l.sessionId,
|
|
86
|
+
sessionFile: '',
|
|
87
|
+
basename: '',
|
|
88
|
+
mtimeMs: l.registeredAtMs,
|
|
89
|
+
origin: l.origin,
|
|
90
|
+
firstPrompt: null,
|
|
91
|
+
live: true,
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
return [...liveOnly, ...disk].sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
95
|
+
}
|
|
96
|
+
|
|
68
97
|
export type ResolveResult =
|
|
69
98
|
| { ok: true; summary: SessionSummary }
|
|
70
99
|
| { ok: false; reason: 'not-found' | 'ambiguous'; matches: SessionSummary[] }
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const EMBEDDING_MODEL_NAME = 'Xenova/multilingual-e5-base'
|
|
5
|
+
export const EMBEDDING_MODEL_DTYPE = 'q8'
|
|
6
|
+
export const EMBEDDING_DIMS = 768
|
|
7
|
+
|
|
8
|
+
// The embedding recipe that makes two vectors comparable: E5 query/passage
|
|
9
|
+
// prefixing + mean pooling + L2 normalize. Stamped in the sentinel (not folded
|
|
10
|
+
// into EMBEDDING_MODEL_ID, which is a stored-row filter — changing the ID would
|
|
11
|
+
// invalidate every existing vector row). A future pooling/normalize change
|
|
12
|
+
// bumps this string so a stale cache fails the sentinel loudly.
|
|
13
|
+
export const EMBEDDING_RECIPE = 'e5-prefix:mean-pool:l2-normalize'
|
|
14
|
+
|
|
15
|
+
// Stored-row identity = name@dtype. Used by the vector store to filter rows
|
|
16
|
+
// from an incompatible model/dtype variant out of cosine scans.
|
|
17
|
+
export const EMBEDDING_MODEL_ID = `${EMBEDDING_MODEL_NAME}@${EMBEDDING_MODEL_DTYPE}`
|
|
18
|
+
|
|
19
|
+
const SENTINEL_FILE = '.typeclaw-model.json'
|
|
20
|
+
|
|
21
|
+
export type ModelSentinel = {
|
|
22
|
+
schemaVersion: 1
|
|
23
|
+
model: string
|
|
24
|
+
dtype: string
|
|
25
|
+
dims: number
|
|
26
|
+
recipe: string
|
|
27
|
+
transformers: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sentinelPath(dir: string): string {
|
|
31
|
+
return join(dir, SENTINEL_FILE)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function expectedSentinel(transformers: string): Omit<ModelSentinel, 'transformers'> & { transformers: string } {
|
|
35
|
+
return {
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
model: EMBEDDING_MODEL_NAME,
|
|
38
|
+
dtype: EMBEDDING_MODEL_DTYPE,
|
|
39
|
+
dims: EMBEDDING_DIMS,
|
|
40
|
+
recipe: EMBEDDING_RECIPE,
|
|
41
|
+
transformers,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Atomic write-then-rename so a container reader can never observe a partial
|
|
46
|
+
// JSON file mid-write. Called host-side after a successful model download,
|
|
47
|
+
// inside the proper-lockfile critical section.
|
|
48
|
+
export async function writeModelSentinel(dir: string, input: { transformers: string }): Promise<void> {
|
|
49
|
+
const sentinel = expectedSentinel(input.transformers)
|
|
50
|
+
const tmp = `${sentinelPath(dir)}.${process.pid}.tmp`
|
|
51
|
+
await writeFile(tmp, `${JSON.stringify(sentinel, null, 2)}\n`, 'utf8')
|
|
52
|
+
await rename(tmp, sentinelPath(dir))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readModelSentinel(dir: string): Promise<ModelSentinel | null> {
|
|
56
|
+
let raw: string
|
|
57
|
+
try {
|
|
58
|
+
raw = await readFile(sentinelPath(dir), 'utf8')
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(raw) as Partial<ModelSentinel>
|
|
64
|
+
if (
|
|
65
|
+
parsed.schemaVersion !== 1 ||
|
|
66
|
+
typeof parsed.model !== 'string' ||
|
|
67
|
+
typeof parsed.dtype !== 'string' ||
|
|
68
|
+
typeof parsed.dims !== 'number' ||
|
|
69
|
+
typeof parsed.recipe !== 'string' ||
|
|
70
|
+
typeof parsed.transformers !== 'string'
|
|
71
|
+
) {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
return parsed as ModelSentinel
|
|
75
|
+
} catch {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Throws a TypeClaw-authored error (naming observed vs expected identity, with
|
|
81
|
+
// the fix) BEFORE the container's `local_files_only` pipeline load — so a
|
|
82
|
+
// host/container drift surfaces as a clear "refresh the cache" message instead
|
|
83
|
+
// of a cryptic missing-file miss, OR worse, a stale file that loads against a
|
|
84
|
+
// different producer's layout and silently returns garbage vectors. Absent
|
|
85
|
+
// sentinel is a hard failure: host ensureModels() writes it before `docker
|
|
86
|
+
// run` in the same `typeclaw start`, so a missing one means the mount is wrong
|
|
87
|
+
// or the cache was hand-copied — exactly the case we must not paper over.
|
|
88
|
+
export async function assertModelCacheCompatible(dir: string, expected: { transformers: string }): Promise<void> {
|
|
89
|
+
const sentinel = await readModelSentinel(dir)
|
|
90
|
+
const want = expectedSentinel(expected.transformers)
|
|
91
|
+
if (sentinel === null) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`TypeClaw model cache at ${dir} is missing or has an unreadable ${SENTINEL_FILE}, so compatibility with ` +
|
|
94
|
+
`this container cannot be verified. Re-run \`typeclaw start\` to refresh the model cache; if it was copied ` +
|
|
95
|
+
`manually, delete it and start again.`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
const mismatches = describeMismatches(sentinel, want)
|
|
99
|
+
if (mismatches.length > 0) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`TypeClaw model cache at ${dir} is incompatible with this container (${mismatches.join('; ')}). ` +
|
|
102
|
+
`Re-run \`typeclaw start\` to refresh the model cache.`,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function describeMismatches(got: ModelSentinel, want: ModelSentinel): string[] {
|
|
108
|
+
const fields: Array<keyof ModelSentinel> = ['model', 'dtype', 'dims', 'recipe', 'transformers']
|
|
109
|
+
return fields
|
|
110
|
+
.filter((field) => got[field] !== want[field])
|
|
111
|
+
.map(
|
|
112
|
+
(field) => `${field}: cache has ${JSON.stringify(got[field])}, container expects ${JSON.stringify(want[field])}`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join, parse as parsePath } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// The ACTUALLY-INSTALLED @huggingface/transformers version in the current
|
|
6
|
+
// runtime, read from the resolved package's own package.json — NOT from
|
|
7
|
+
// typeclaw's dependency spec (which is the intended version, not what is on
|
|
8
|
+
// disk). The model-cache sentinel compares this across stages: the host
|
|
9
|
+
// stamps the version that produced the download, the container checks the
|
|
10
|
+
// version that will consume it. Comparing two intended constants would miss
|
|
11
|
+
// exactly the drift this guards — "the installed runtime isn't what the build
|
|
12
|
+
// said it should be" (e.g. a lockfile-free `bun add` resolving a newer
|
|
13
|
+
// release). Resolution is isolated here so the package-internals access lives
|
|
14
|
+
// in one place.
|
|
15
|
+
//
|
|
16
|
+
// We resolve the package's EXPORTED entry and walk up to its package.json,
|
|
17
|
+
// rather than `require('@huggingface/transformers/package.json')`: that subpath
|
|
18
|
+
// is not in the package's `exports` map (only `node`/`default`), so a strict
|
|
19
|
+
// Node-exports resolver throws ERR_PACKAGE_PATH_NOT_EXPORTED. The main entry IS
|
|
20
|
+
// exported, and its package.json is the nearest one above the resolved file.
|
|
21
|
+
export function getResolvedTransformersVersion(): string {
|
|
22
|
+
const require = createRequire(import.meta.url)
|
|
23
|
+
const entry = require.resolve('@huggingface/transformers')
|
|
24
|
+
const version = readNearestPackageVersion(dirname(entry))
|
|
25
|
+
if (version === null) {
|
|
26
|
+
throw new Error('could not resolve @huggingface/transformers version from its package.json')
|
|
27
|
+
}
|
|
28
|
+
return version
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readNearestPackageVersion(startDir: string): string | null {
|
|
32
|
+
const root = parsePath(startDir).root
|
|
33
|
+
let dir = startDir
|
|
34
|
+
for (;;) {
|
|
35
|
+
const version = readPackageNameVersion(join(dir, 'package.json'))
|
|
36
|
+
if (version !== null) return version
|
|
37
|
+
if (dir === root) return null
|
|
38
|
+
dir = dirname(dir)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Only accept the @huggingface/transformers package.json, never a nested
|
|
43
|
+
// dependency's: the resolved entry can sit under dist/, and an intermediate
|
|
44
|
+
// dir could in theory carry an unrelated package.json. Match on name.
|
|
45
|
+
function readPackageNameVersion(pkgPath: string): string | null {
|
|
46
|
+
let parsed: { name?: unknown; version?: unknown }
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: unknown; version?: unknown }
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
if (parsed.name !== '@huggingface/transformers') return null
|
|
53
|
+
if (typeof parsed.version !== 'string' || parsed.version.length === 0) return null
|
|
54
|
+
return parsed.version
|
|
55
|
+
}
|
package/src/plugin/types.ts
CHANGED
|
@@ -182,6 +182,9 @@ export type SessionTurnStartEvent = {
|
|
|
182
182
|
agentDir: string
|
|
183
183
|
userPrompt: string
|
|
184
184
|
origin?: SessionOrigin
|
|
185
|
+
// Mutable ref: plugin writes retrieval results here; server/router reads after hook returns.
|
|
186
|
+
// Only populated when vector.enabled and injection plan is index mode.
|
|
187
|
+
retrievalContext?: { results: string }
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
export type SessionTurnEndEvent = {
|