@pi-unipi/unipi 3.0.0-alpha.2 → 3.0.0-alpha.21
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/CHANGELOG.md +292 -0
- package/README.md +8 -11
- package/docs/prefix-cache-architecture.md +14 -0
- package/package.json +22 -24
- package/packages/ask-user/README.md +55 -107
- package/packages/ask-user/ask-ui.ts +306 -732
- package/packages/ask-user/clipboard.ts +50 -0
- package/packages/ask-user/config.ts +74 -78
- package/packages/ask-user/package.json +2 -2
- package/packages/ask-user/questions.ts +151 -0
- package/packages/ask-user/skills/ask-user/SKILL.md +59 -133
- package/packages/ask-user/tools.ts +198 -432
- package/packages/autocomplete/package.json +1 -1
- package/packages/autocomplete/src/constants.ts +38 -43
- package/packages/autocomplete/src/sorting.ts +8 -0
- package/packages/background-tasks/package.json +2 -2
- package/packages/background-tasks/src/config.ts +0 -73
- package/packages/background-tasks/src/index.ts +20 -16
- package/packages/background-tasks/src/registry.ts +1 -171
- package/packages/background-tasks/src/types.ts +0 -32
- package/packages/btw/README.md +13 -56
- package/packages/btw/extensions/btw.ts +403 -1796
- package/packages/btw/package.json +5 -2
- package/packages/btw/skills/btw/SKILL.md +7 -143
- package/packages/compactor/README.md +56 -287
- package/packages/compactor/package.json +5 -9
- package/packages/compactor/skills/compactor/SKILL.md +13 -20
- package/packages/compactor/src/card.ts +90 -0
- package/packages/compactor/src/commands/index.ts +73 -130
- package/packages/compactor/src/compaction/compact-args.ts +5 -3
- package/packages/compactor/src/compaction/filter-noise.ts +1 -1
- package/packages/compactor/src/compaction/hooks.ts +487 -311
- package/packages/compactor/src/compaction/source.ts +178 -0
- package/packages/compactor/src/compaction/summarize.ts +586 -52
- package/packages/compactor/src/config/manager.ts +130 -127
- package/packages/compactor/src/config/schema.ts +32 -48
- package/packages/compactor/src/index.ts +40 -431
- package/packages/compactor/src/info-screen.ts +44 -190
- package/packages/compactor/src/session/recall-blocks.ts +1 -1
- package/packages/compactor/src/stats.ts +76 -0
- package/packages/compactor/src/tools/context-budget.ts +17 -34
- package/packages/compactor/src/tools/ctx-doctor.ts +45 -36
- package/packages/compactor/src/tools/register.ts +29 -269
- package/packages/compactor/src/types.ts +47 -186
- package/packages/core/README.md +25 -0
- package/packages/core/child-agent.ts +1 -0
- package/packages/core/command-echo.ts +63 -0
- package/packages/core/compaction-context.ts +41 -0
- package/packages/core/constants.ts +8 -38
- package/packages/core/events.ts +0 -53
- package/packages/core/fusion-status.ts +4 -0
- package/packages/core/global.d.ts +10 -0
- package/packages/core/harness-messages.ts +392 -0
- package/packages/core/index.ts +14 -0
- package/packages/core/kanboard-status.ts +49 -0
- package/packages/core/long-horizon-owner-status.ts +46 -0
- package/packages/core/model-cache.ts +81 -7
- package/packages/core/package.json +6 -3
- package/packages/core/src/attach/detect.ts +163 -0
- package/packages/core/src/child-agent/child-args.ts +31 -0
- package/packages/core/src/child-agent/delivery.ts +41 -0
- package/packages/core/src/child-agent/index.ts +5 -0
- package/packages/core/src/child-agent/readers.ts +203 -0
- package/packages/core/src/child-agent/runtime.ts +584 -0
- package/packages/core/src/evidence.ts +78 -0
- package/packages/core/src/hints/browser.ts +278 -0
- package/packages/core/src/hints/crab-data.ts +19 -0
- package/packages/core/src/hints/index.ts +865 -0
- package/packages/core/src/hints/lines.ts +607 -0
- package/packages/core/src/hints/store.ts +120 -0
- package/packages/core/src/jev/chatter.ts +24 -0
- package/packages/core/src/jev/settings.ts +177 -14
- package/packages/core/src/package-colors.ts +2 -0
- package/packages/core/src/settings/catalog.ts +39 -15
- package/packages/core/src/settings/engine.ts +48 -2
- package/packages/core/src/settings/hub.ts +83 -20
- package/packages/core/src/settings/open.ts +49 -0
- package/packages/core/src/settings/schema.ts +27 -7
- package/packages/core/src/tui/hub-kit.ts +18 -5
- package/packages/core/src/tui/kit.ts +198 -0
- package/packages/core/src/tui/progress.ts +73 -0
- package/packages/core/src/turn/arbiter.ts +290 -0
- package/packages/footer/README.md +4 -6
- package/packages/footer/package.json +3 -3
- package/packages/footer/src/glance-editor.ts +19 -11
- package/packages/footer/src/help.ts +1 -1
- package/packages/footer/src/index.ts +36 -3
- package/packages/footer/src/presets.ts +3 -4
- package/packages/footer/src/rendering/icons.ts +0 -20
- package/packages/footer/src/segments/compactor.ts +52 -142
- package/packages/footer/src/segments/status-ext.ts +0 -1
- package/packages/fusion/README.md +14 -4
- package/packages/fusion/package.json +4 -3
- package/packages/fusion/src/child-args.ts +1 -0
- package/packages/fusion/src/index.ts +161 -8
- package/packages/fusion/src/pi-settings.ts +57 -0
- package/packages/fusion/src/picker.ts +186 -51
- package/packages/fusion/src/preset.ts +56 -2
- package/packages/fusion/src/prompts.ts +1 -1
- package/packages/fusion/src/sidekick-runtime.ts +1 -420
- package/packages/fusion/src/tools.ts +107 -128
- package/packages/fusion/src/transcript.ts +27 -85
- package/packages/info-screen/package.json +2 -2
- package/packages/input-shortcuts/README.md +18 -30
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/input-shortcuts/src/burst.ts +88 -0
- package/packages/input-shortcuts/src/chord-overlay.ts +20 -70
- package/packages/input-shortcuts/src/cleared-input.ts +97 -0
- package/packages/input-shortcuts/src/index.ts +99 -193
- package/packages/input-shortcuts/src/last-response.ts +40 -0
- package/packages/input-shortcuts/src/registers.ts +8 -23
- package/packages/input-shortcuts/src/types.ts +4 -16
- package/packages/input-shortcuts/src/undo-redo.ts +59 -47
- package/packages/kanboard/README.md +51 -47
- package/packages/kanboard/index.ts +293 -55
- package/packages/kanboard/package.json +8 -8
- package/packages/kanboard/skills/kanboard/SKILL.md +61 -27
- package/packages/kanboard/src/badges.ts +93 -0
- package/packages/kanboard/src/bin.ts +4 -2
- package/packages/kanboard/src/commands.ts +218 -84
- package/packages/kanboard/src/debug.ts +20 -0
- package/packages/kanboard/src/guard.ts +267 -109
- package/packages/kanboard/src/monitor.ts +330 -0
- package/packages/kanboard/src/notice-buffer.ts +74 -0
- package/packages/kanboard/src/progress.ts +27 -0
- package/packages/kanboard/src/reminders.ts +234 -0
- package/packages/kanboard/src/settings.ts +69 -15
- package/packages/kanboard/src/shapes.ts +5 -0
- package/packages/long-horizon/index.ts +190 -13
- package/packages/long-horizon/package.json +3 -3
- package/packages/long-horizon/src/commands.ts +103 -7
- package/packages/long-horizon/src/compaction-brief.ts +35 -0
- package/packages/long-horizon/src/engine/continuation.ts +84 -15
- package/packages/long-horizon/src/engine/goal-state.ts +64 -13
- package/packages/long-horizon/src/engine/nudge-stash.ts +71 -0
- package/packages/long-horizon/src/engine/ralph.ts +3 -2
- package/packages/long-horizon/src/engine/runaway.ts +68 -11
- package/packages/long-horizon/src/engine/verifier.ts +19 -0
- package/packages/long-horizon/src/gate.ts +37 -12
- package/packages/long-horizon/src/judge/resolve.ts +1 -0
- package/packages/long-horizon/src/owner.ts +35 -2
- package/packages/long-horizon/src/progress.ts +87 -0
- package/packages/long-horizon/src/prompts/goal.ts +1 -1
- package/packages/long-horizon/src/runtime.ts +172 -18
- package/packages/long-horizon/src/settings.ts +54 -39
- package/packages/long-horizon/src/tools/goal.ts +6 -0
- package/packages/long-horizon/src/tools/graph.ts +1 -1
- package/packages/long-horizon/src/tools/swarm.ts +3 -3
- package/packages/mcp/package.json +2 -2
- package/packages/memory/commands.ts +7 -2
- package/packages/memory/index.ts +210 -32
- package/packages/memory/package.json +3 -3
- package/packages/memory/save-session.ts +264 -0
- package/packages/memory/settings.ts +17 -0
- package/packages/memory/tools.ts +253 -203
- package/packages/notify/package.json +2 -2
- package/packages/skill-registry/README.md +53 -16
- package/packages/skill-registry/index.ts +230 -0
- package/packages/skill-registry/package.json +19 -8
- package/packages/skill-registry/src/editor.ts +206 -0
- package/packages/skill-registry/src/judge.ts +289 -0
- package/packages/skill-registry/src/registry.ts +87 -0
- package/packages/skill-registry/src/settings.ts +259 -0
- package/packages/skill-registry/src/vault.ts +80 -0
- package/packages/subagents/README.md +39 -119
- package/packages/subagents/package.json +6 -13
- package/packages/subagents/skills/subagents/SKILL.md +18 -42
- package/packages/subagents/src/agents.ts +213 -0
- package/packages/subagents/src/cards.ts +182 -0
- package/packages/subagents/src/index.ts +609 -1084
- package/packages/subagents/src/manager.ts +429 -0
- package/packages/subagents/src/profiles.ts +130 -0
- package/packages/subagents/src/transcript.ts +154 -0
- package/packages/subagents/src/ui.ts +389 -0
- package/packages/unipi/bundled.js +39756 -45569
- package/packages/unipi/index.ts +55 -10
- package/packages/updater/package.json +2 -2
- package/packages/utility/README.md +76 -69
- package/packages/utility/package.json +2 -2
- package/packages/utility/skills/image/SKILL.md +37 -0
- package/packages/utility/skills/utility/SKILL.md +17 -60
- package/packages/utility/src/answer/extract.ts +128 -0
- package/packages/utility/src/answer/index.ts +213 -0
- package/packages/utility/src/answer/reply.ts +133 -0
- package/packages/utility/src/answer/web.ts +167 -0
- package/packages/utility/src/attach/index.ts +205 -0
- package/packages/utility/src/commands.ts +38 -142
- package/packages/utility/src/diagnostics/engine.ts +50 -5
- package/packages/utility/src/image/index.ts +33 -0
- package/packages/utility/src/image/models.ts +221 -0
- package/packages/{image/src → utility/src/image}/recognize.ts +2 -2
- package/packages/utility/src/image/settings.ts +178 -0
- package/packages/{image/src/image-source.ts → utility/src/image/source.ts} +1 -1
- package/packages/utility/src/image/tools.ts +168 -0
- package/packages/utility/src/image/transports.ts +186 -0
- package/packages/utility/src/index.ts +68 -389
- package/packages/utility/src/lifecycle/cleanup.ts +116 -311
- package/packages/utility/src/rename/gate.ts +102 -0
- package/packages/utility/src/rename/index.ts +191 -0
- package/packages/utility/src/rename/session.ts +170 -0
- package/packages/utility/src/render/delegated.ts +285 -0
- package/packages/utility/src/render/harness.ts +683 -0
- package/packages/utility/src/render/parse.ts +156 -0
- package/packages/utility/src/render/pi-settings.ts +38 -0
- package/packages/utility/src/render/reply-bg.ts +307 -0
- package/packages/utility/src/render/simple.ts +724 -0
- package/packages/utility/src/render/spacing.ts +235 -0
- package/packages/utility/src/render/styled.ts +251 -0
- package/packages/utility/src/render/tools.ts +353 -0
- package/packages/utility/src/settings.ts +166 -233
- package/packages/utility/src/types.ts +1 -222
- package/packages/watchdog/index.ts +17 -6
- package/packages/watchdog/package.json +3 -3
- package/packages/watchdog/src/config.ts +3 -2
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/README.md +3 -0
- package/packages/workflow/index.ts +0 -2
- package/packages/workflow/package.json +2 -2
- package/packages/workflow/src/permission/decide.ts +6 -4
- package/packages/workflow/src/permission/index.ts +17 -7
- package/packages/workflow/src/permission/settings.ts +39 -4
- package/packages/workflow/src/plan/index.ts +19 -4
- package/packages/background-tasks/extensions/delegate-child.ts +0 -1
- package/packages/background-tasks/src/context-parent-snapshot.ts +0 -142
- package/packages/background-tasks/src/context-token-budget.ts +0 -903
- package/packages/background-tasks/src/context-visible-conversation-v2.ts +0 -551
- package/packages/background-tasks/src/delegate/artifacts.ts +0 -487
- package/packages/background-tasks/src/delegate/budget.ts +0 -415
- package/packages/background-tasks/src/delegate/hook-contract-evidence.json +0 -18
- package/packages/background-tasks/src/delegate/hook-contract.ts +0 -154
- package/packages/background-tasks/src/delegate/launch.ts +0 -484
- package/packages/background-tasks/src/delegate/result-package.ts +0 -459
- package/packages/background-tasks/src/delegate/runner.ts +0 -430
- package/packages/background-tasks/src/delegate/seed.ts +0 -423
- package/packages/background-tasks/src/delegate/types.ts +0 -323
- package/packages/background-tasks/src/delegate-child-extension.ts +0 -978
- package/packages/background-tasks/src/delegate-extension.ts +0 -654
- package/packages/background-tasks/src/fixtures/delegate-context-incident.json +0 -17
- package/packages/compactor/skills/compactor-detail/SKILL.md +0 -116
- package/packages/compactor/skills/compactor-doctor/SKILL.md +0 -71
- package/packages/compactor/skills/compactor-stats/SKILL.md +0 -51
- package/packages/compactor/src/compaction/build-sections.ts +0 -76
- package/packages/compactor/src/compaction/extract/goals.ts +0 -36
- package/packages/compactor/src/compaction/extract/preferences.ts +0 -40
- package/packages/compactor/src/compaction/format.ts +0 -53
- package/packages/compactor/src/compaction/merge.ts +0 -143
- package/packages/compactor/src/config/presets.ts +0 -160
- package/packages/compactor/src/executor/executor.ts +0 -233
- package/packages/compactor/src/executor/runtime.ts +0 -115
- package/packages/compactor/src/security/evaluator.ts +0 -89
- package/packages/compactor/src/security/policy.ts +0 -96
- package/packages/compactor/src/security/scanner.ts +0 -65
- package/packages/compactor/src/session/auto-inject.ts +0 -60
- package/packages/compactor/src/session/db.ts +0 -325
- package/packages/compactor/src/session/extract.ts +0 -144
- package/packages/compactor/src/session/resume-inject.ts +0 -64
- package/packages/compactor/src/session/snapshot.ts +0 -327
- package/packages/compactor/src/tools/ctx-batch-execute.ts +0 -41
- package/packages/compactor/src/tools/ctx-execute-file.ts +0 -23
- package/packages/compactor/src/tools/ctx-execute.ts +0 -23
- package/packages/compactor/src/tools/ctx-stats.ts +0 -96
- package/packages/image/README.md +0 -99
- package/packages/image/package.json +0 -61
- package/packages/image/skills/image/SKILL.md +0 -76
- package/packages/image/src/generate.ts +0 -256
- package/packages/image/src/index.ts +0 -135
- package/packages/image/src/models.ts +0 -519
- package/packages/image/src/openai-images-api.ts +0 -282
- package/packages/image/src/register-providers.ts +0 -209
- package/packages/image/src/settings.ts +0 -173
- package/packages/image/src/tools.ts +0 -326
- package/packages/input-shortcuts/src/clipboard.ts +0 -119
- package/packages/kanboard/src/runner.ts +0 -817
- package/packages/skill-registry/skills/auto/SKILL.md +0 -465
- package/packages/skill-registry/skills/brainstorm/SKILL.md +0 -219
- package/packages/skill-registry/skills/chore-create/SKILL.md +0 -315
- package/packages/skill-registry/skills/chore-execute/SKILL.md +0 -314
- package/packages/skill-registry/skills/consolidate/SKILL.md +0 -142
- package/packages/skill-registry/skills/consultant/SKILL.md +0 -97
- package/packages/skill-registry/skills/debug/SKILL.md +0 -228
- package/packages/skill-registry/skills/document/SKILL.md +0 -120
- package/packages/skill-registry/skills/fix/SKILL.md +0 -212
- package/packages/skill-registry/skills/gather-context/SKILL.md +0 -145
- package/packages/skill-registry/skills/plan/SKILL.md +0 -231
- package/packages/skill-registry/skills/quick-fix/SKILL.md +0 -135
- package/packages/skill-registry/skills/quick-work/SKILL.md +0 -110
- package/packages/skill-registry/skills/research/SKILL.md +0 -251
- package/packages/skill-registry/skills/review-work/SKILL.md +0 -187
- package/packages/skill-registry/skills/scan-issues/SKILL.md +0 -195
- package/packages/skill-registry/skills/work/SKILL.md +0 -218
- package/packages/skill-registry/skills/worktree-create/SKILL.md +0 -69
- package/packages/skill-registry/skills/worktree-list/SKILL.md +0 -78
- package/packages/skill-registry/skills/worktree-merge/SKILL.md +0 -89
- package/packages/subagents/agents/delegate.md +0 -14
- package/packages/subagents/agents/oracle.md +0 -78
- package/packages/subagents/agents/researcher.md +0 -52
- package/packages/subagents/agents/reviewer.md +0 -80
- package/packages/subagents/agents/scout.md +0 -50
- package/packages/subagents/agents/worker.md +0 -59
- package/packages/subagents/prompts/council.md +0 -48
- package/packages/subagents/prompts/gather-context-and-clarify.md +0 -13
- package/packages/subagents/prompts/parallel-cleanup.md +0 -59
- package/packages/subagents/prompts/parallel-research.md +0 -50
- package/packages/subagents/prompts/parallel-review.md +0 -54
- package/packages/subagents/prompts/review-loop.md +0 -43
- package/packages/subagents/skills/council-mode/SKILL.md +0 -230
- package/packages/subagents/skills/subagents/references/constraints-and-recipes.md +0 -259
- package/packages/subagents/skills/subagents/references/execution-controls.md +0 -454
- package/packages/subagents/skills/subagents/references/management-authoring-rpc.md +0 -161
- package/packages/subagents/skills/subagents/references/multi-lane-orchestration.md +0 -39
- package/packages/subagents/skills/subagents/references/prompting-and-roles.md +0 -267
- package/packages/subagents/src/acceptance.ts +0 -331
- package/packages/subagents/src/agent-manager.ts +0 -405
- package/packages/subagents/src/agent-memory.ts +0 -300
- package/packages/subagents/src/agent-overrides.ts +0 -183
- package/packages/subagents/src/agent-runner.ts +0 -271
- package/packages/subagents/src/async-runner.ts +0 -422
- package/packages/subagents/src/authority-policy.ts +0 -47
- package/packages/subagents/src/budgets.ts +0 -285
- package/packages/subagents/src/child-safety.ts +0 -140
- package/packages/subagents/src/config.ts +0 -249
- package/packages/subagents/src/conversation-viewer.ts +0 -281
- package/packages/subagents/src/core-compat.ts +0 -94
- package/packages/subagents/src/custom-agents.ts +0 -343
- package/packages/subagents/src/file-system-retry.ts +0 -50
- package/packages/subagents/src/fleet-data.ts +0 -105
- package/packages/subagents/src/fleet-view.ts +0 -322
- package/packages/subagents/src/foreground-detach.ts +0 -59
- package/packages/subagents/src/fork-context.ts +0 -285
- package/packages/subagents/src/global.d.ts +0 -7
- package/packages/subagents/src/guide.ts +0 -129
- package/packages/subagents/src/mission-state.ts +0 -133
- package/packages/subagents/src/mission-store.ts +0 -436
- package/packages/subagents/src/output-limits.ts +0 -142
- package/packages/subagents/src/parity-types.ts +0 -315
- package/packages/subagents/src/pi-args.ts +0 -167
- package/packages/subagents/src/result-files.ts +0 -232
- package/packages/subagents/src/result-watcher.ts +0 -221
- package/packages/subagents/src/retained-children.ts +0 -176
- package/packages/subagents/src/run-fanout-budget.ts +0 -356
- package/packages/subagents/src/scheduled-runs.ts +0 -349
- package/packages/subagents/src/schemas.ts +0 -263
- package/packages/subagents/src/slash-commands.ts +0 -80
- package/packages/subagents/src/supervisor-channel.ts +0 -249
- package/packages/subagents/src/tool-handler.ts +0 -1144
- package/packages/subagents/src/types.ts +0 -238
- package/packages/subagents/src/widget.ts +0 -457
- package/packages/subagents/src/workflow-script.ts +0 -737
- package/packages/subagents/src/workflow-worker.ts +0 -384
- package/packages/subagents/src/worktree.ts +0 -614
- package/packages/utility/src/analytics/collector.ts +0 -293
- package/packages/utility/src/info-screen.ts +0 -80
- package/packages/utility/src/lifecycle/process.ts +0 -162
- package/packages/utility/src/prefix-cache.ts +0 -263
- package/packages/utility/src/skill-discovery.ts +0 -542
- package/packages/utility/src/tools/env.ts +0 -113
- package/packages/utility/src/tui/name-badge-state.ts +0 -315
- package/packages/utility/src/tui/name-badge.ts +0 -109
- /package/packages/{subagents/src → core/src/child-agent}/pi-spawn.ts +0 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — image model catalog, routing and keys
|
|
3
|
+
*
|
|
4
|
+
* Catalog (merged into the shared model cache with real modalities):
|
|
5
|
+
* - OpenRouter's image models (public /models?output_modalities=image),
|
|
6
|
+
* refreshed at most daily into ~/.unipi/config/image-models-cache.json
|
|
7
|
+
* - a short list of fal models (listed when a fal key is available)
|
|
8
|
+
* - pi's chat models already in the cache (vision = input includes image)
|
|
9
|
+
*
|
|
10
|
+
* Routing a "provider/model-id" reference:
|
|
11
|
+
* openrouter/… → OpenRouter chat+modalities key: settings → pi login → OPENROUTER_API_KEY
|
|
12
|
+
* fal/… → fal.run key: settings → FAL_KEY / FAL_API_KEY
|
|
13
|
+
* <other>/… → that pi provider's baseUrl, OpenAI images shape, pi's key for it
|
|
14
|
+
* custom endpoint set in settings → that endpoint, model id as typed, its key
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { readModelCache, type CachedModel, type Modality } from "@pi-unipi/core";
|
|
20
|
+
import type { EndpointSettings, ImageApi, ImageConfig } from "./settings.js";
|
|
21
|
+
|
|
22
|
+
export interface ModelRegistryLike {
|
|
23
|
+
find(provider: string, id: string): unknown;
|
|
24
|
+
getAll(): unknown[];
|
|
25
|
+
getAvailable?(): unknown[];
|
|
26
|
+
getApiKeyForProvider?(provider: string): Promise<string | undefined>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function splitRef(ref: string): { provider: string; id: string } | null {
|
|
30
|
+
const t = ref.trim();
|
|
31
|
+
const slash = t.indexOf("/");
|
|
32
|
+
return slash > 0 && slash < t.length - 1 ? { provider: t.slice(0, slash), id: t.slice(slash + 1) } : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── catalog ──────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export const FAL_MODELS: CachedModel[] = [
|
|
38
|
+
{ provider: "fal", id: "fal-ai/flux-2/klein/4b", name: "FLUX.2 [klein] 4B", input: ["text"], output: ["image"], kind: "images" },
|
|
39
|
+
{ provider: "fal", id: "fal-ai/flux-2/klein/4b/edit", name: "FLUX.2 [klein] 4B edit", input: ["text", "image"], output: ["image"], kind: "images" },
|
|
40
|
+
{ provider: "fal", id: "fal-ai/flux-2", name: "FLUX.2 [dev]", input: ["text"], output: ["image"], kind: "images" },
|
|
41
|
+
{ provider: "fal", id: "fal-ai/flux-2/edit", name: "FLUX.2 [dev] edit", input: ["text", "image"], output: ["image"], kind: "images" },
|
|
42
|
+
{ provider: "fal", id: "fal-ai/nano-banana-pro", name: "Nano Banana Pro", input: ["text"], output: ["image"], kind: "images" },
|
|
43
|
+
{ provider: "fal", id: "fal-ai/nano-banana-pro/edit", name: "Nano Banana Pro edit", input: ["text", "image"], output: ["image"], kind: "images" },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const IMAGE_CACHE_TTL_MS = 24 * 3600_000;
|
|
47
|
+
|
|
48
|
+
function imageCachePath(): string {
|
|
49
|
+
return path.join(process.env.HOME ?? process.env.USERPROFILE ?? "~", ".unipi", "config", "image-models-cache.json");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function readImageModelCache(): { updatedAt: number; models: CachedModel[] } {
|
|
53
|
+
try {
|
|
54
|
+
const raw = JSON.parse(fs.readFileSync(imageCachePath(), "utf-8")) as { updatedAt?: number; models?: CachedModel[] };
|
|
55
|
+
return { updatedAt: raw.updatedAt ?? 0, models: Array.isArray(raw.models) ? raw.models : [] };
|
|
56
|
+
} catch {
|
|
57
|
+
return { updatedAt: 0, models: [] };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const modalities = (list: unknown): Modality[] =>
|
|
62
|
+
Array.isArray(list) ? (list.filter((m) => m === "text" || m === "image") as Modality[]) : [];
|
|
63
|
+
|
|
64
|
+
/** OpenRouter /models (image output) → cache entries. */
|
|
65
|
+
export function parseOpenRouterImageModels(json: unknown): CachedModel[] {
|
|
66
|
+
const data = (json as { data?: unknown[] })?.data;
|
|
67
|
+
if (!Array.isArray(data)) return [];
|
|
68
|
+
const out: CachedModel[] = [];
|
|
69
|
+
for (const raw of data) {
|
|
70
|
+
const m = raw as { id?: unknown; name?: unknown; architecture?: { input_modalities?: unknown; output_modalities?: unknown } };
|
|
71
|
+
if (typeof m.id !== "string" || m.id.startsWith("openrouter/")) continue;
|
|
72
|
+
const output = modalities(m.architecture?.output_modalities);
|
|
73
|
+
if (!output.includes("image")) continue;
|
|
74
|
+
const input = modalities(m.architecture?.input_modalities);
|
|
75
|
+
out.push({ provider: "openrouter", id: m.id, ...(typeof m.name === "string" ? { name: m.name } : {}), input: input.length ? input : ["text"], output, kind: "images" });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Refresh the OpenRouter image list when stale; never throws. */
|
|
81
|
+
export async function refreshImageModelCache(fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<boolean> {
|
|
82
|
+
const current = readImageModelCache();
|
|
83
|
+
if (current.models.length > 0 && now - current.updatedAt < IMAGE_CACHE_TTL_MS) return false;
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetchImpl("https://openrouter.ai/api/v1/models?output_modalities=image", { signal: AbortSignal.timeout(15_000) });
|
|
86
|
+
if (!res.ok) return false;
|
|
87
|
+
const models = parseOpenRouterImageModels(await res.json());
|
|
88
|
+
if (models.length === 0) return false;
|
|
89
|
+
fs.mkdirSync(path.dirname(imageCachePath()), { recursive: true });
|
|
90
|
+
fs.writeFileSync(imageCachePath(), JSON.stringify({ updatedAt: now, models }, null, 2));
|
|
91
|
+
return true;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function falKey(config: ImageConfig, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
98
|
+
return config.keys.fal || env.FAL_KEY || env.FAL_API_KEY || undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Image entries to merge into the shared model cache (first wins on dupes). */
|
|
102
|
+
export function imageCatalogEntries(config: ImageConfig, env: NodeJS.ProcessEnv = process.env): CachedModel[] {
|
|
103
|
+
return [...readImageModelCache().models, ...(falKey(config, env) ? FAL_MODELS : [])];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Modalities of a reference, from the shared cache (undefined = unknown). */
|
|
107
|
+
export function lookupModel(ref: string): CachedModel | undefined {
|
|
108
|
+
return readModelCache().find((m) => `${m.provider}/${m.id}` === ref);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── routing ──────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export interface Route {
|
|
114
|
+
api: ImageApi;
|
|
115
|
+
baseUrl: string;
|
|
116
|
+
apiKey: string;
|
|
117
|
+
modelId: string;
|
|
118
|
+
label: string;
|
|
119
|
+
output?: string[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function providerBaseUrl(registry: ModelRegistryLike | undefined, provider: string): string | undefined {
|
|
123
|
+
try {
|
|
124
|
+
const models = (registry?.getAvailable?.() ?? registry?.getAll() ?? []) as Array<{ provider?: string; baseUrl?: string }>;
|
|
125
|
+
return models.find((m) => m.provider === provider && m.baseUrl)?.baseUrl;
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function registryKey(registry: ModelRegistryLike | undefined, provider: string, env: NodeJS.ProcessEnv): Promise<string | undefined> {
|
|
132
|
+
try {
|
|
133
|
+
const key = await registry?.getApiKeyForProvider?.(provider);
|
|
134
|
+
if (key) return key;
|
|
135
|
+
} catch {
|
|
136
|
+
// fall through
|
|
137
|
+
}
|
|
138
|
+
return env[`${provider.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`] || undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Where and how to send a generate/edit request. Throws a message that says
|
|
143
|
+
* exactly what to set when a key or endpoint is missing.
|
|
144
|
+
*/
|
|
145
|
+
export async function resolveRoute(
|
|
146
|
+
ref: string,
|
|
147
|
+
endpoint: EndpointSettings,
|
|
148
|
+
config: ImageConfig,
|
|
149
|
+
registry: ModelRegistryLike | undefined,
|
|
150
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
151
|
+
): Promise<Route> {
|
|
152
|
+
const model = ref.trim();
|
|
153
|
+
if (!model) throw new Error("No image model configured — pick one in /unipi:settings → Image.");
|
|
154
|
+
if (endpoint.baseUrl.trim()) {
|
|
155
|
+
if (!endpoint.apiKey) throw new Error(`The custom image endpoint ${endpoint.baseUrl} has no key — set it in /unipi:settings → Image.`);
|
|
156
|
+
return { api: endpoint.api, baseUrl: endpoint.baseUrl.trim(), apiKey: endpoint.apiKey, modelId: model, label: `${model} @ ${endpoint.baseUrl.trim()}` };
|
|
157
|
+
}
|
|
158
|
+
const parts = splitRef(model);
|
|
159
|
+
if (!parts) throw new Error(`"${model}" is not a provider/model-id reference.`);
|
|
160
|
+
const output = lookupModel(model)?.output;
|
|
161
|
+
if (parts.provider === "openrouter") {
|
|
162
|
+
const apiKey = config.keys.openrouter || (await registryKey(registry, "openrouter", env));
|
|
163
|
+
if (!apiKey) throw new Error("No OpenRouter key. Set Keys → OpenRouter key in /unipi:settings → Image, log in with /login, or export OPENROUTER_API_KEY.");
|
|
164
|
+
return { api: "openrouter", baseUrl: "", apiKey, modelId: parts.id, label: model, ...(output ? { output } : {}) };
|
|
165
|
+
}
|
|
166
|
+
if (parts.provider === "fal") {
|
|
167
|
+
const apiKey = falKey(config, env);
|
|
168
|
+
if (!apiKey) throw new Error("No fal key. Set Keys → fal key in /unipi:settings → Image, or export FAL_KEY.");
|
|
169
|
+
return { api: "fal", baseUrl: "", apiKey, modelId: parts.id, label: model };
|
|
170
|
+
}
|
|
171
|
+
const baseUrl = providerBaseUrl(registry, parts.provider);
|
|
172
|
+
if (!baseUrl) throw new Error(`Provider "${parts.provider}" is not configured in pi, so there is no endpoint for ${model}. Pick an openrouter/ or fal/ model, or set a custom endpoint.`);
|
|
173
|
+
const apiKey = await registryKey(registry, parts.provider, env);
|
|
174
|
+
if (!apiKey) throw new Error(`No key for provider "${parts.provider}". Log in with /login or set a custom endpoint key.`);
|
|
175
|
+
return { api: "openai-images", baseUrl, apiKey, modelId: parts.id, label: model };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── vision (recognize) ───────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
export interface VisionModel {
|
|
181
|
+
id: string;
|
|
182
|
+
provider: string;
|
|
183
|
+
name?: string;
|
|
184
|
+
input?: string[];
|
|
185
|
+
baseUrl?: string;
|
|
186
|
+
api?: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function isVisionModel(model: unknown): model is VisionModel {
|
|
190
|
+
const m = model as Partial<VisionModel> | null;
|
|
191
|
+
return !!m && typeof m.id === "string" && typeof m.provider === "string" && Array.isArray(m.input) && m.input.includes("image");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Active tools with image_recognize hidden for vision models, restored for text-only ones. */
|
|
195
|
+
export function applyRecognizeGating(active: string[], model: unknown, tool: string): string[] {
|
|
196
|
+
const vision = isVisionModel(model);
|
|
197
|
+
const present = active.includes(tool);
|
|
198
|
+
if (vision !== present) return active;
|
|
199
|
+
return vision ? active.filter((n) => n !== tool) : [...active, tool];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A registry vision model by reference (exact, then id, then substring). */
|
|
203
|
+
export function resolveVisionModel(ref: string, registry: ModelRegistryLike): VisionModel | string {
|
|
204
|
+
let all: unknown[] = [];
|
|
205
|
+
try {
|
|
206
|
+
all = registry.getAvailable?.() ?? registry.getAll();
|
|
207
|
+
} catch {
|
|
208
|
+
all = [];
|
|
209
|
+
}
|
|
210
|
+
const vision = all.filter(isVisionModel);
|
|
211
|
+
const q = ref.trim().toLowerCase();
|
|
212
|
+
const full = (m: VisionModel) => `${m.provider}/${m.id}`.toLowerCase();
|
|
213
|
+
const hit = vision.find((m) => full(m) === q) ?? vision.find((m) => m.id.toLowerCase() === q) ?? vision.find((m) => full(m).includes(q));
|
|
214
|
+
if (hit) return hit;
|
|
215
|
+
const known = all.find((m) => {
|
|
216
|
+
const c = m as Partial<VisionModel>;
|
|
217
|
+
return typeof c.id === "string" && typeof c.provider === "string" && (`${c.provider}/${c.id}`.toLowerCase() === q || c.id.toLowerCase() === q);
|
|
218
|
+
});
|
|
219
|
+
if (known) return `Model "${ref}" does not accept image input. Vision models: ${vision.slice(0, 5).map((m) => `${m.provider}/${m.id}`).join(", ")}`;
|
|
220
|
+
return `Unknown vision model "${ref}". Vision models: ${vision.slice(0, 5).map((m) => `${m.provider}/${m.id}`).join(", ") || "none configured"}`;
|
|
221
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @pi-unipi/
|
|
2
|
+
* @pi-unipi/utility — image: Image recognition
|
|
3
3
|
*
|
|
4
4
|
* Sends an image plus a question to a vision-capable chat model. Providers
|
|
5
5
|
* differ in how image parts are encoded, so the request is built per API
|
|
6
6
|
* family (mirroring `packages/notify/summarize.ts`).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { LoadedImage } from "./
|
|
9
|
+
import type { LoadedImage } from "./source.js";
|
|
10
10
|
|
|
11
11
|
/** How long to wait for a vision response. Images are slow. */
|
|
12
12
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — image settings (namespace `image`, unchanged from the
|
|
3
|
+
* former @pi-unipi/image package, so existing configs keep working)
|
|
4
|
+
*
|
|
5
|
+
* generate — text → image (models whose output includes image)
|
|
6
|
+
* edit — image+text → image (models that take an image and output one)
|
|
7
|
+
* recognize — image → text (vision chat models; hidden while the
|
|
8
|
+
* session model can see images itself)
|
|
9
|
+
* keys — OpenRouter / fal keys (empty = pi's login or the environment)
|
|
10
|
+
* *.baseUrl — optional custom endpoint: when set, the model id is sent to
|
|
11
|
+
* that endpoint as typed, with its own key.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { getSettings, registerSettings } from "@pi-unipi/core";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_RECOGNIZE_SYSTEM_PROMPT =
|
|
19
|
+
"You are a precise image analyst assisting a software engineer. " +
|
|
20
|
+
"Describe what is actually visible — never speculate about what is not shown. " +
|
|
21
|
+
"For screenshots, transcribe visible text, UI structure, and any errors verbatim. " +
|
|
22
|
+
"For diagrams, describe the components and their relationships. " +
|
|
23
|
+
"For photographs, describe the subject, setting, and notable detail. " +
|
|
24
|
+
"Be specific and concise; lead with the single most important observation.";
|
|
25
|
+
|
|
26
|
+
/** Cheap, fast defaults (FLUX.2 klein 4B does both text→image and edits). */
|
|
27
|
+
export const DEFAULT_GENERATE_MODEL = "openrouter/black-forest-labs/flux.2-klein-4b";
|
|
28
|
+
export const DEFAULT_EDIT_MODEL = "openrouter/black-forest-labs/flux.2-klein-4b";
|
|
29
|
+
|
|
30
|
+
export type ImageApi = "openai-images" | "openrouter" | "fal";
|
|
31
|
+
|
|
32
|
+
export interface EndpointSettings {
|
|
33
|
+
/** Custom endpoint; "" = route by the model's provider. */
|
|
34
|
+
baseUrl: string;
|
|
35
|
+
apiKey: string;
|
|
36
|
+
api: ImageApi;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GenerateSettings extends EndpointSettings {
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
model: string;
|
|
42
|
+
outputDir: string;
|
|
43
|
+
saveToDisk: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface EditSettings extends EndpointSettings {
|
|
47
|
+
enabled: boolean;
|
|
48
|
+
model: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RecognizeSettings {
|
|
52
|
+
enabled: boolean;
|
|
53
|
+
/** "" = the session model. */
|
|
54
|
+
model: string;
|
|
55
|
+
systemPrompt: string;
|
|
56
|
+
/** Custom OpenAI-compatible chat endpoint; "" = the model's provider. */
|
|
57
|
+
baseUrl: string;
|
|
58
|
+
apiKey: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ImageConfig {
|
|
62
|
+
generate: GenerateSettings;
|
|
63
|
+
edit: EditSettings;
|
|
64
|
+
recognize: RecognizeSettings;
|
|
65
|
+
keys: { openrouter: string; fal: string };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const NO_ENDPOINT: EndpointSettings = { baseUrl: "", apiKey: "", api: "openai-images" };
|
|
69
|
+
|
|
70
|
+
export const DEFAULT_CONFIG: ImageConfig = {
|
|
71
|
+
generate: { enabled: true, model: DEFAULT_GENERATE_MODEL, outputDir: "~/.unipi/images", saveToDisk: true, ...NO_ENDPOINT },
|
|
72
|
+
edit: { enabled: true, model: DEFAULT_EDIT_MODEL, ...NO_ENDPOINT },
|
|
73
|
+
recognize: { enabled: true, model: "", systemPrompt: DEFAULT_RECOGNIZE_SYSTEM_PROMPT, baseUrl: "", apiKey: "" },
|
|
74
|
+
keys: { openrouter: "", fal: "" },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const API_OPTIONS = [
|
|
78
|
+
{ value: "openai-images", label: "OpenAI images (/images/generations)" },
|
|
79
|
+
{ value: "openrouter", label: "OpenRouter-style chat (modalities)" },
|
|
80
|
+
{ value: "fal", label: "fal (fal.run/<model>)" },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
registerSettings({
|
|
84
|
+
namespace: "image",
|
|
85
|
+
label: "Image",
|
|
86
|
+
defaults: DEFAULT_CONFIG as unknown as Record<string, unknown>,
|
|
87
|
+
schema: [
|
|
88
|
+
{
|
|
89
|
+
title: "Generate",
|
|
90
|
+
description: "Text → image (image_generate)",
|
|
91
|
+
fields: [
|
|
92
|
+
{ key: "generate.enabled", type: "boolean", label: "Enabled", description: "Give the agent the image_generate tool" },
|
|
93
|
+
{ key: "generate.model", type: "model", label: "Model", capability: "image-output", description: "Models whose output includes images" },
|
|
94
|
+
{ key: "generate.outputDir", type: "string", label: "Save to folder", description: "~ is expanded" },
|
|
95
|
+
{ key: "generate.saveToDisk", type: "boolean", label: "Save to disk" },
|
|
96
|
+
{ key: "generate.baseUrl", type: "string", label: "Custom endpoint", emptyLabel: "none (use the model's provider)", description: "Send the model id as typed to this base URL instead" },
|
|
97
|
+
{ key: "generate.apiKey", type: "secret", label: "Custom endpoint key", emptyLabel: "none" },
|
|
98
|
+
{ key: "generate.api", type: "enum", label: "Custom endpoint API", options: API_OPTIONS },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
title: "Edit",
|
|
103
|
+
description: "Image + text → image (image_edit)",
|
|
104
|
+
fields: [
|
|
105
|
+
{ key: "edit.enabled", type: "boolean", label: "Enabled", description: "Give the agent the image_edit tool" },
|
|
106
|
+
{ key: "edit.model", type: "model", label: "Model", capability: "image-edit", description: "Models that take an image and output an image" },
|
|
107
|
+
{ key: "edit.baseUrl", type: "string", label: "Custom endpoint", emptyLabel: "none (use the model's provider)" },
|
|
108
|
+
{ key: "edit.apiKey", type: "secret", label: "Custom endpoint key", emptyLabel: "none" },
|
|
109
|
+
{ key: "edit.api", type: "enum", label: "Custom endpoint API", options: API_OPTIONS },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
title: "Recognize",
|
|
114
|
+
description: "Image → text (image_recognize; hidden while the session model can see images)",
|
|
115
|
+
fields: [
|
|
116
|
+
{ key: "recognize.enabled", type: "boolean", label: "Enabled", description: "Give text-only models the image_recognize tool" },
|
|
117
|
+
{ key: "recognize.model", type: "model", label: "Model", capability: "image-input", emptyLabel: "inherit (session model)", emptyOption: "inherit (session model)" },
|
|
118
|
+
{ key: "recognize.systemPrompt", type: "string", label: "System prompt", emptyLabel: "built-in analyst prompt" },
|
|
119
|
+
{ key: "recognize.baseUrl", type: "string", label: "Custom endpoint", emptyLabel: "none (use the model's provider)", description: "OpenAI-compatible chat endpoint" },
|
|
120
|
+
{ key: "recognize.apiKey", type: "secret", label: "Custom endpoint key", emptyLabel: "none" },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
title: "Keys",
|
|
125
|
+
description: "Used when the model's provider is OpenRouter or fal",
|
|
126
|
+
advanced: true,
|
|
127
|
+
fields: [
|
|
128
|
+
{ key: "keys.openrouter", type: "secret", label: "OpenRouter key", emptyLabel: "pi login or OPENROUTER_API_KEY" },
|
|
129
|
+
{ key: "keys.fal", type: "secret", label: "fal key", emptyLabel: "FAL_KEY / FAL_API_KEY" },
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
136
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Merge a loaded section over its defaults, ignoring wrong-typed fields. */
|
|
140
|
+
function mergeSection<T extends object>(defaults: T, loaded: unknown): T {
|
|
141
|
+
const merged: T = { ...defaults };
|
|
142
|
+
if (!isRecord(loaded)) return merged;
|
|
143
|
+
for (const key of Object.keys(defaults) as Array<keyof T & string>) {
|
|
144
|
+
const value = loaded[key];
|
|
145
|
+
if (value !== undefined && value !== null && typeof value === typeof defaults[key]) merged[key] = value as T[keyof T & string];
|
|
146
|
+
}
|
|
147
|
+
return merged;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function loadConfig(cwd: string = process.cwd()): ImageConfig {
|
|
151
|
+
try {
|
|
152
|
+
const parsed = getSettings("image", cwd);
|
|
153
|
+
if (!isRecord(parsed)) return structuredClone(DEFAULT_CONFIG);
|
|
154
|
+
const config: ImageConfig = {
|
|
155
|
+
generate: mergeSection(DEFAULT_CONFIG.generate, parsed.generate),
|
|
156
|
+
edit: mergeSection(DEFAULT_CONFIG.edit, parsed.edit),
|
|
157
|
+
recognize: mergeSection(DEFAULT_CONFIG.recognize, parsed.recognize),
|
|
158
|
+
keys: mergeSection(DEFAULT_CONFIG.keys, parsed.keys),
|
|
159
|
+
};
|
|
160
|
+
for (const section of [config.generate, config.edit]) {
|
|
161
|
+
if (!["openai-images", "openrouter", "fal"].includes(section.api)) section.api = "openai-images";
|
|
162
|
+
}
|
|
163
|
+
return config;
|
|
164
|
+
} catch {
|
|
165
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function expandHome(target: string): string {
|
|
170
|
+
if (target === "~") return os.homedir();
|
|
171
|
+
if (target.startsWith("~/") || target.startsWith("~\\")) return path.join(os.homedir(), target.slice(2));
|
|
172
|
+
return target;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function getOutputDir(config: ImageConfig = loadConfig()): string {
|
|
176
|
+
const dir = config.generate.outputDir?.trim();
|
|
177
|
+
return expandHome(dir && dir.length > 0 ? dir : DEFAULT_CONFIG.generate.outputDir);
|
|
178
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — image tools: image_generate, image_edit, image_recognize
|
|
3
|
+
*
|
|
4
|
+
* Registered once at load from the enabled flags (static schemas keep the
|
|
5
|
+
* provider prefix cache intact). Failures THROW so pi records them as failed
|
|
6
|
+
* tool results — returning `isError` is ignored by pi.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { IMAGE_TOOLS } from "@pi-unipi/core";
|
|
14
|
+
import { loadImage } from "./source.js";
|
|
15
|
+
import { recognizeImage } from "./recognize.js";
|
|
16
|
+
import { generate, type ImagePayload } from "./transports.js";
|
|
17
|
+
import { resolveRoute, resolveVisionModel, type ModelRegistryLike } from "./models.js";
|
|
18
|
+
import { getOutputDir, loadConfig, type EndpointSettings } from "./settings.js";
|
|
19
|
+
|
|
20
|
+
const EXT: Record<string, string> = { "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif" };
|
|
21
|
+
|
|
22
|
+
export function slugify(prompt: string, max = 40): string {
|
|
23
|
+
return prompt.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/g, "") || "image";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Save images; a failed write never loses the generated image. */
|
|
27
|
+
export function saveImages(dir: string, prompt: string, images: ImagePayload[], now = new Date()): string[] {
|
|
28
|
+
const stamp = now.toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
|
|
29
|
+
const saved: string[] = [];
|
|
30
|
+
images.forEach((img, i) => {
|
|
31
|
+
try {
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
const file = path.join(dir, `${stamp}-${slugify(prompt)}${i ? `-${i + 1}` : ""}${EXT[img.mimeType] ?? ".png"}`);
|
|
34
|
+
fs.writeFileSync(file, Buffer.from(img.data, "base64"));
|
|
35
|
+
saved.push(file);
|
|
36
|
+
} catch {
|
|
37
|
+
// keep going — the image is still returned inline
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return saved;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function registry(ctx: ExtensionContext): ModelRegistryLike | undefined {
|
|
44
|
+
return (ctx as unknown as { modelRegistry?: ModelRegistryLike }).modelRegistry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function run(
|
|
48
|
+
ctx: ExtensionContext,
|
|
49
|
+
kind: "generate" | "edit",
|
|
50
|
+
prompt: string,
|
|
51
|
+
modelOverride: string | undefined,
|
|
52
|
+
endpoint: EndpointSettings & { model: string },
|
|
53
|
+
source: ImagePayload[] | undefined,
|
|
54
|
+
signal: AbortSignal | undefined,
|
|
55
|
+
) {
|
|
56
|
+
const config = loadConfig(ctx.cwd);
|
|
57
|
+
const route = await resolveRoute(modelOverride?.trim() || endpoint.model, modelOverride?.trim() ? { ...endpoint, baseUrl: "" } : endpoint, config, registry(ctx));
|
|
58
|
+
const out = await generate({ ...route, prompt, ...(source ? { images: source } : {}), ...(signal ? { signal } : {}) });
|
|
59
|
+
const saved = config.generate.saveToDisk ? saveImages(getOutputDir(config), prompt, out.images) : [];
|
|
60
|
+
const verb = kind === "edit" ? "Edited" : "Generated";
|
|
61
|
+
const summary = [
|
|
62
|
+
`${verb} ${out.images.length} image${out.images.length === 1 ? "" : "s"} with ${route.label}.`,
|
|
63
|
+
saved.length ? `Saved to:\n${saved.map((p) => ` ${p}`).join("\n")}` : config.generate.saveToDisk ? "Could not save to disk; the image is inline only." : "",
|
|
64
|
+
out.text,
|
|
65
|
+
].filter(Boolean).join("\n");
|
|
66
|
+
return {
|
|
67
|
+
content: [{ type: "text" as const, text: summary }, ...out.images.map((i) => ({ type: "image" as const, data: i.data, mimeType: i.mimeType }))],
|
|
68
|
+
details: { model: route.label, count: out.images.length, paths: saved },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function registerImageTools(pi: ExtensionAPI): void {
|
|
73
|
+
const config = loadConfig();
|
|
74
|
+
|
|
75
|
+
if (config.generate.enabled) {
|
|
76
|
+
pi.registerTool({
|
|
77
|
+
name: IMAGE_TOOLS.GENERATE,
|
|
78
|
+
label: "Generate Image",
|
|
79
|
+
description: "Generate an image from a text prompt. Returned inline and saved to disk.",
|
|
80
|
+
promptSnippet: "Generate an image from a text prompt.",
|
|
81
|
+
promptGuidelines: [
|
|
82
|
+
"Write a detailed prompt — subject, style, composition and lighting all help.",
|
|
83
|
+
"Describe what you DO want; negation is unreliable in image models.",
|
|
84
|
+
"To change an existing image use image_edit instead.",
|
|
85
|
+
"Omit model to use the one configured in /unipi:settings. Images cost money; do not regenerate unasked.",
|
|
86
|
+
],
|
|
87
|
+
parameters: Type.Object({
|
|
88
|
+
prompt: Type.String({ description: "Description of the image. Be specific." }),
|
|
89
|
+
model: Type.Optional(Type.String({ description: 'Override as "provider/model-id", e.g. "openrouter/black-forest-labs/flux.2-klein-4b" or "fal/fal-ai/flux-2/klein/4b".' })),
|
|
90
|
+
}),
|
|
91
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
92
|
+
const c = loadConfig(ctx.cwd);
|
|
93
|
+
return run(ctx, "generate", params.prompt, params.model, c.generate, undefined, signal);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (config.edit.enabled) {
|
|
99
|
+
pi.registerTool({
|
|
100
|
+
name: IMAGE_TOOLS.EDIT,
|
|
101
|
+
label: "Edit Image",
|
|
102
|
+
description: "Edit an existing image with a text instruction. Returned inline and saved to disk.",
|
|
103
|
+
promptSnippet: "Edit an existing image with a text instruction.",
|
|
104
|
+
promptGuidelines: [
|
|
105
|
+
"Pass the source image as a file path (preferred), data: URL or base64.",
|
|
106
|
+
"Say what to change and what to keep; unmentioned details may change.",
|
|
107
|
+
],
|
|
108
|
+
parameters: Type.Object({
|
|
109
|
+
prompt: Type.String({ description: "What to change." }),
|
|
110
|
+
image: Type.String({ description: "Source image: local file path, data: URL, or base64." }),
|
|
111
|
+
model: Type.Optional(Type.String({ description: 'Override as "provider/model-id" (must accept an image and output one).' })),
|
|
112
|
+
}),
|
|
113
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
114
|
+
const c = loadConfig(ctx.cwd);
|
|
115
|
+
const src = loadImage(params.image, ctx.cwd ?? process.cwd());
|
|
116
|
+
return run(ctx, "edit", params.prompt, params.model, c.edit, [{ data: src.data, mimeType: src.mimeType }], signal);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (config.recognize.enabled) {
|
|
122
|
+
pi.registerTool({
|
|
123
|
+
name: IMAGE_TOOLS.RECOGNIZE,
|
|
124
|
+
label: "Recognize Image",
|
|
125
|
+
description: "Analyze an image and answer questions about it using a vision model.",
|
|
126
|
+
promptSnippet: "Analyze an image and answer questions about it.",
|
|
127
|
+
promptGuidelines: [
|
|
128
|
+
"Use image_recognize to read screenshots, diagrams, mockups and photos.",
|
|
129
|
+
"Pass a local file path when possible — cheaper than inlining base64.",
|
|
130
|
+
"Ask a specific question in `prompt` to focus the analysis.",
|
|
131
|
+
],
|
|
132
|
+
parameters: Type.Object({
|
|
133
|
+
image: Type.String({ description: "Local file path, data: URL, or base64 (PNG, JPEG, GIF, WebP)." }),
|
|
134
|
+
prompt: Type.Optional(Type.String({ description: "What to ask about the image. Defaults to a general description." })),
|
|
135
|
+
model: Type.Optional(Type.String({ description: "Vision model override (must accept image input)." })),
|
|
136
|
+
}),
|
|
137
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
138
|
+
const c = loadConfig(ctx.cwd).recognize;
|
|
139
|
+
const image = loadImage(params.image, ctx.cwd ?? process.cwd());
|
|
140
|
+
const prompt = params.prompt?.trim() || "Describe this image in detail.";
|
|
141
|
+
let target: { baseUrl: string; apiKey: string; api: string; modelId: string; label: string };
|
|
142
|
+
const requested = params.model?.trim() || c.model.trim();
|
|
143
|
+
if (c.baseUrl.trim() && !params.model?.trim()) {
|
|
144
|
+
if (!requested) throw new Error("Set a model id for the custom recognition endpoint in /unipi:settings → Image.");
|
|
145
|
+
target = { baseUrl: c.baseUrl.trim(), apiKey: c.apiKey, api: "openai-completions", modelId: requested, label: `${requested} @ ${c.baseUrl.trim()}` };
|
|
146
|
+
} else {
|
|
147
|
+
const reg = registry(ctx);
|
|
148
|
+
if (!reg) throw new Error("Model registry unavailable — image_recognize needs an active session.");
|
|
149
|
+
const session = (ctx as unknown as { model?: { provider?: string; id?: string } }).model;
|
|
150
|
+
const ref = requested || (session?.provider && session.id ? `${session.provider}/${session.id}` : "");
|
|
151
|
+
if (!ref) throw new Error("No vision model configured — choose one in /unipi:settings → Image.");
|
|
152
|
+
const model = resolveVisionModel(ref, reg);
|
|
153
|
+
if (typeof model === "string") throw new Error(model);
|
|
154
|
+
const full = reg.find(model.provider, model.id) as { baseUrl?: string; api?: string } | undefined;
|
|
155
|
+
if (!full?.baseUrl) throw new Error(`No endpoint known for ${model.provider}/${model.id}.`);
|
|
156
|
+
const apiKey = (await reg.getApiKeyForProvider?.(model.provider).catch(() => undefined)) ?? "";
|
|
157
|
+
if (!apiKey) throw new Error(`No key for provider "${model.provider}". Log in with /login.`);
|
|
158
|
+
target = { baseUrl: full.baseUrl, apiKey, api: full.api ?? "openai-completions", modelId: model.id, label: `${model.provider}/${model.id}` };
|
|
159
|
+
}
|
|
160
|
+
const result = await recognizeImage({ image, prompt, systemPrompt: c.systemPrompt, apiKey: target.apiKey, baseUrl: target.baseUrl, api: target.api, modelId: target.modelId, ...(signal ? { signal } : {}) });
|
|
161
|
+
return {
|
|
162
|
+
content: [{ type: "text" as const, text: `${result.text}\n\n— analyzed with ${target.label}${image.path ? ` (${image.path})` : ""}` }],
|
|
163
|
+
details: { model: target.label, mimeType: image.mimeType, ...(image.path ? { path: image.path } : {}) },
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|