sanook-cli 0.4.0 → 0.5.0
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/.env.example +19 -0
- package/CHANGELOG.md +144 -0
- package/README.md +153 -20
- package/README.th.md +136 -0
- package/dist/agentContext.js +4 -0
- package/dist/approval.js +6 -0
- package/dist/bin.js +394 -51
- package/dist/brain.js +92 -59
- package/dist/brand.js +47 -0
- package/dist/checkpoint.js +37 -0
- package/dist/commands.js +86 -6
- package/dist/compaction.js +76 -5
- package/dist/config.js +100 -12
- package/dist/cost.js +60 -3
- package/dist/doctor.js +92 -0
- package/dist/gateway/auth.js +2 -2
- package/dist/gateway/ledger.js +2 -2
- package/dist/gateway/scheduler.js +1 -0
- package/dist/gateway/serve.js +6 -4
- package/dist/gateway/server.js +10 -2
- package/dist/git.js +11 -2
- package/dist/hooks.js +43 -17
- package/dist/knowledge.js +48 -49
- package/dist/loop.js +182 -66
- package/dist/lsp/client.js +173 -0
- package/dist/lsp/framing.js +56 -0
- package/dist/lsp/index.js +138 -0
- package/dist/lsp/servers.js +82 -0
- package/dist/mcp-server.js +244 -0
- package/dist/mcp.js +184 -29
- package/dist/memory-store.js +559 -0
- package/dist/memory.js +143 -29
- package/dist/orchestrate.js +150 -0
- package/dist/providers/codex.js +2 -2
- package/dist/providers/keys.js +3 -2
- package/dist/providers/registry.js +133 -1
- package/dist/repomap.js +93 -0
- package/dist/search/chunk.js +158 -0
- package/dist/search/embed-store.js +187 -0
- package/dist/search/engine.js +203 -0
- package/dist/search/fuse.js +35 -0
- package/dist/search/index-core.js +187 -0
- package/dist/search/indexer.js +241 -0
- package/dist/search/store.js +77 -0
- package/dist/session.js +42 -8
- package/dist/skill-install.js +10 -10
- package/dist/skills.js +12 -9
- package/dist/summarize.js +31 -0
- package/dist/tools/bash.js +21 -2
- package/dist/tools/diagnostics.js +41 -0
- package/dist/tools/edit.js +29 -7
- package/dist/tools/index.js +8 -1
- package/dist/tools/list.js +7 -2
- package/dist/tools/permission.js +90 -9
- package/dist/tools/read.js +23 -4
- package/dist/tools/remember.js +1 -1
- package/dist/tools/sandbox.js +61 -0
- package/dist/tools/search.js +105 -4
- package/dist/tools/task.js +195 -29
- package/dist/tools/timeout.js +35 -0
- package/dist/tools/util.js +10 -0
- package/dist/tools/write.js +6 -4
- package/dist/trust.js +89 -0
- package/dist/ui/app.js +218 -27
- package/dist/ui/banner.js +4 -9
- package/dist/ui/history.js +30 -0
- package/dist/ui/mentions.js +44 -0
- package/dist/ui/setup.js +6 -5
- package/dist/ui/useEditor.js +83 -0
- package/dist/update.js +114 -0
- package/dist/worktree.js +173 -0
- package/package.json +11 -5
- package/scripts/postinstall.mjs +33 -0
- package/second-brain/.agents/_Index.md +30 -0
- package/second-brain/.agents/skills/_Index.md +30 -0
- package/second-brain/.agents/workflows/_Index.md +30 -0
- package/second-brain/AGENTS.md +4 -4
- package/second-brain/Acceptance/_Index.md +30 -0
- package/second-brain/Acceptance/golden-case-template.md +39 -0
- package/second-brain/Areas/_Index.md +30 -0
- package/second-brain/Bugs/System-OS/_Index.md +30 -0
- package/second-brain/Bugs/_Index.md +30 -0
- package/second-brain/CLAUDE.md +4 -1
- package/second-brain/Checklists/_Index.md +30 -0
- package/second-brain/Checklists/preflight-postflight-template.md +29 -0
- package/second-brain/Distillations/_Index.md +30 -0
- package/second-brain/Entities/_Index.md +30 -0
- package/second-brain/Entities/entity-template.md +33 -0
- package/second-brain/Evals/_Index.md +30 -0
- package/second-brain/Evals/correction-pairs.md +24 -0
- package/second-brain/Evals/failure-taxonomy.md +24 -0
- package/second-brain/Evals/golden-set.md +25 -0
- package/second-brain/Evals/quality-ledger.md +23 -0
- package/second-brain/Evals/self-eval-rubric.md +23 -0
- package/second-brain/GEMINI.md +4 -4
- package/second-brain/Goals/_Index.md +30 -0
- package/second-brain/Handoffs/_Index.md +30 -0
- package/second-brain/Home.md +7 -0
- package/second-brain/Intake/Raw Sources/_Index.md +30 -0
- package/second-brain/Intake/_Index.md +30 -0
- package/second-brain/Intake/_Quarantine/_Index.md +30 -0
- package/second-brain/Learning/_Index.md +30 -0
- package/second-brain/Playbooks/_Index.md +30 -0
- package/second-brain/Playbooks/playbook-template.md +23 -0
- package/second-brain/Projects/_Index.md +30 -0
- package/second-brain/Prompts/_Index.md +30 -0
- package/second-brain/README.md +2 -1
- package/second-brain/Research/_Index.md +30 -0
- package/second-brain/Retrospectives/_Index.md +30 -0
- package/second-brain/Reviews/_Index.md +30 -0
- package/second-brain/Runbooks/_Index.md +30 -0
- package/second-brain/Runbooks/eval-loop.md +24 -0
- package/second-brain/Sessions/_Index.md +30 -0
- package/second-brain/Shared/AI-Context-Index.md +20 -0
- package/second-brain/Shared/AI-Threads/_Index.md +30 -0
- package/second-brain/Shared/Archive/_Index.md +30 -0
- package/second-brain/Shared/Assets/_Index.md +30 -0
- package/second-brain/Shared/Context-Packs/_Index.md +30 -0
- package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
- package/second-brain/Shared/Coordination/NOW.md +28 -0
- package/second-brain/Shared/Coordination/_Index.md +30 -0
- package/second-brain/Shared/Coordination/agent-registry.md +24 -0
- package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
- package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
- package/second-brain/Shared/Coordination/task-board.md +32 -0
- package/second-brain/Shared/Core-Facts/_Index.md +30 -0
- package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
- package/second-brain/Shared/Glossary/_Index.md +30 -0
- package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
- package/second-brain/Shared/Operating-State/_Index.md +30 -0
- package/second-brain/Shared/Prompting/_Index.md +30 -0
- package/second-brain/Shared/Provenance/_Index.md +30 -0
- package/second-brain/Shared/Rules/_Index.md +30 -0
- package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
- package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
- package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
- package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
- package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
- package/second-brain/Shared/Rules/rules-formatting.md +34 -0
- package/second-brain/Shared/Scripts/_Index.md +30 -0
- package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
- package/second-brain/Shared/User-Memory/_Index.md +30 -0
- package/second-brain/Shared/User-Persona/_Index.md +30 -0
- package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
- package/second-brain/Shared/Working-Memory/_Index.md +30 -0
- package/second-brain/Shared/_Index.md +30 -0
- package/second-brain/Shared/mcp-servers/_Index.md +30 -0
- package/second-brain/Skills/_Index.md +30 -0
- package/second-brain/Templates/_Index.md +30 -0
- package/second-brain/Templates/bug.md +2 -0
- package/second-brain/Templates/handoff.md +2 -0
- package/second-brain/Templates/session.md +2 -0
- package/second-brain/Tools/_Index.md +30 -0
- package/second-brain/Traces/_Index.md +30 -0
- package/second-brain/Vault Structure Map.md +33 -1
- package/second-brain/copilot/_Index.md +30 -0
- package/skills/audit-license-compliance/SKILL.md +117 -0
- package/skills/author-codemod/SKILL.md +110 -0
- package/skills/build-audit-logging/SKILL.md +112 -0
- package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
- package/skills/build-cli-tool/SKILL.md +108 -0
- package/skills/build-data-table/SKILL.md +141 -0
- package/skills/build-native-mobile-ui/SKILL.md +154 -0
- package/skills/build-offline-first-sync/SKILL.md +118 -0
- package/skills/build-realtime-channel/SKILL.md +122 -0
- package/skills/build-vector-search/SKILL.md +131 -0
- package/skills/compose-local-dev-stack/SKILL.md +149 -0
- package/skills/configure-bundler-build/SKILL.md +166 -0
- package/skills/configure-dns-tls/SKILL.md +142 -0
- package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
- package/skills/configure-security-headers-csp/SKILL.md +122 -0
- package/skills/contract-testing/SKILL.md +140 -0
- package/skills/datetime-timezone-correctness/SKILL.md +125 -0
- package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
- package/skills/debug-flaky-tests/SKILL.md +128 -0
- package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
- package/skills/deliver-webhooks/SKILL.md +116 -0
- package/skills/design-api-pagination/SKILL.md +144 -0
- package/skills/design-authorization-model/SKILL.md +119 -0
- package/skills/design-backup-dr-recovery/SKILL.md +113 -0
- package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
- package/skills/design-multi-tenancy/SKILL.md +100 -0
- package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
- package/skills/design-relational-schema/SKILL.md +129 -0
- package/skills/design-search-index-infra/SKILL.md +151 -0
- package/skills/design-state-machine/SKILL.md +108 -0
- package/skills/design-token-system/SKILL.md +109 -0
- package/skills/distributed-locks-leases/SKILL.md +120 -0
- package/skills/encrypt-sensitive-data/SKILL.md +148 -0
- package/skills/feature-flags-rollout/SKILL.md +130 -0
- package/skills/file-upload-object-storage/SKILL.md +107 -0
- package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
- package/skills/harden-llm-app-reliability/SKILL.md +126 -0
- package/skills/i18n-localization-setup/SKILL.md +113 -0
- package/skills/idempotency-keys/SKILL.md +107 -0
- package/skills/implement-push-notifications/SKILL.md +142 -0
- package/skills/ingest-webhook-secure/SKILL.md +120 -0
- package/skills/integrate-oauth-oidc/SKILL.md +126 -0
- package/skills/load-stress-test/SKILL.md +129 -0
- package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
- package/skills/model-nosql-data/SKILL.md +118 -0
- package/skills/money-decimal-arithmetic/SKILL.md +123 -0
- package/skills/monitor-ml-drift/SKILL.md +109 -0
- package/skills/numeric-precision-units/SKILL.md +144 -0
- package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
- package/skills/optimize-react-rerenders/SKILL.md +124 -0
- package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
- package/skills/payments-billing-integration/SKILL.md +114 -0
- package/skills/pin-toolchain-versions/SKILL.md +116 -0
- package/skills/plan-strangler-migration/SKILL.md +95 -0
- package/skills/property-based-testing/SKILL.md +108 -0
- package/skills/publish-package-registry/SKILL.md +130 -0
- package/skills/recover-git-state/SKILL.md +119 -0
- package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
- package/skills/resilience-timeouts-retries/SKILL.md +104 -0
- package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
- package/skills/rewrite-git-history/SKILL.md +109 -0
- package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
- package/skills/schema-evolution-compatibility/SKILL.md +121 -0
- package/skills/send-transactional-email/SKILL.md +126 -0
- package/skills/serve-deploy-ml-model/SKILL.md +107 -0
- package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
- package/skills/setup-devcontainer-env/SKILL.md +131 -0
- package/skills/setup-lint-format-precommit/SKILL.md +140 -0
- package/skills/setup-monorepo-tooling/SKILL.md +125 -0
- package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
- package/skills/structured-output-llm/SKILL.md +86 -0
- package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
- package/skills/test-data-factories/SKILL.md +158 -0
- package/skills/threat-model-stride/SKILL.md +123 -0
- package/skills/train-evaluate-ml-model/SKILL.md +109 -0
- package/skills/unicode-text-correctness/SKILL.md +109 -0
- package/skills/visual-regression-testing/SKILL.md +120 -0
package/dist/memory.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { readFile, writeFile,
|
|
2
|
-
import { homedir } from 'node:os';
|
|
1
|
+
import { readFile, writeFile, stat } from 'node:fs/promises';
|
|
3
2
|
import { join, dirname, resolve } from 'node:path';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
3
|
+
import { appHomePath, BRAND, persistenceEnabled, worklogEnabled } from './brand.js';
|
|
4
|
+
import { redactKey } from './providers/keys.js';
|
|
5
|
+
import { loadStore, saveStore, mergeFact, maybeConsolidate, consolidate, renderPromptBlock } from './memory-store.js';
|
|
6
|
+
const MEMORY_FILE = BRAND.memoryFileName;
|
|
7
|
+
// auto-memory (สิ่งที่ agent จำเองข้าม session) ย้ายไปอยู่ใน ./memory-store.ts —
|
|
8
|
+
// memory.json เป็น source of truth, MEMORY.md เป็น view ที่ render จากมัน
|
|
8
9
|
// เดินขึ้นหยุดที่ project root — ไม่เลยขึ้นไปถึง filesystem root
|
|
9
10
|
// (กัน prompt-injection จาก SANOOK.md ที่ใครก็วางใน parent dir ที่ share กันได้)
|
|
10
11
|
const BOUNDARY_MARKERS = ['.git', 'package.json'];
|
|
@@ -40,7 +41,7 @@ export async function loadMemory(cwd = process.cwd()) {
|
|
|
40
41
|
dir = parent;
|
|
41
42
|
}
|
|
42
43
|
chain.reverse(); // project root ก่อน → cwd ท้าย (local override general)
|
|
43
|
-
const paths = [
|
|
44
|
+
const paths = [appHomePath(MEMORY_FILE), ...chain.map((d) => join(d, MEMORY_FILE))];
|
|
44
45
|
const blocks = [];
|
|
45
46
|
const seen = new Set();
|
|
46
47
|
for (const p of paths) {
|
|
@@ -58,11 +59,14 @@ export async function loadMemory(cwd = process.cwd()) {
|
|
|
58
59
|
}
|
|
59
60
|
return blocks.join('\n\n');
|
|
60
61
|
}
|
|
61
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* โหลด auto-memory เข้า system prompt — render จาก structured store (./memory-store.ts)
|
|
64
|
+
* เป็น block ที่ rank + cap แล้ว (top facts ตาม importance·recency, ≤ ~2k token กัน context-rot)
|
|
65
|
+
* contract เดิม: '' ถ้าว่าง, ไม่งั้นคืน <auto_memory> block เดียวที่ self-contained
|
|
66
|
+
*/
|
|
62
67
|
export async function loadAutoMemory() {
|
|
63
68
|
try {
|
|
64
|
-
|
|
65
|
-
return content ? `<auto_memory note="สิ่งที่จำไว้จาก session ก่อน">\n${content}\n</auto_memory>` : '';
|
|
69
|
+
return renderPromptBlock(await loadStore());
|
|
66
70
|
}
|
|
67
71
|
catch {
|
|
68
72
|
return '';
|
|
@@ -74,35 +78,145 @@ export async function loadAutoMemory() {
|
|
|
74
78
|
* brainPath มาจาก ~/.sanook/config.json · ไม่มี/ไฟล์หาย → คืน '' (เงียบ)
|
|
75
79
|
*/
|
|
76
80
|
export async function loadBrainContext() {
|
|
81
|
+
const brainPath = await getBrainPath();
|
|
82
|
+
return brainPath ? buildBrainContext(brainPath) : '';
|
|
83
|
+
}
|
|
84
|
+
/** ประกอบ brain context จาก vault path (pure → testable) — entry + current-state + remembered facts */
|
|
85
|
+
export async function buildBrainContext(brainPath) {
|
|
86
|
+
const parts = [];
|
|
87
|
+
// 1. entry/pointers (Vault Structure Map ฯลฯ)
|
|
88
|
+
const idx = await readTrimmed(join(brainPath, 'Shared', 'AI-Context-Index.md'), 3000);
|
|
89
|
+
if (idx)
|
|
90
|
+
parts.push(idx);
|
|
91
|
+
// 2. current-state — เนื้อจริง (live focus) ไม่ใช่แค่ pointer
|
|
92
|
+
const cs = await readTrimmed(join(brainPath, 'Shared', 'Operating-State', 'current-state.md'), 1500);
|
|
93
|
+
if (cs)
|
|
94
|
+
parts.push(`## current-state\n${cs}`);
|
|
95
|
+
// 3. ปิด loop: fact ที่ remember ไว้ (Memory-Inbox) กลับเข้า context — ไม่งั้น vault = write-only
|
|
96
|
+
const inbox = await inboxCandidates(join(brainPath, 'Shared', 'Memory-Inbox', 'memory-inbox.md'), 1200);
|
|
97
|
+
if (inbox)
|
|
98
|
+
parts.push(`## remembered (Memory-Inbox)\n${inbox}`);
|
|
99
|
+
if (!parts.length)
|
|
100
|
+
return '';
|
|
101
|
+
return `<brain_vault path="${brainPath}" note="second-brain ของ user — สิ่งที่จำไว้/state ปัจจุบันอยู่ใน block นี้; route โน้ตตาม Vault Structure Map; อ่าน/เขียนไฟล์ใน vault ด้วย absolute path ได้">\n${parts.join('\n\n')}\n</brain_vault>`;
|
|
102
|
+
}
|
|
103
|
+
/** อ่านไฟล์ + trim หัว N ตัว ('' ถ้าไม่มี/ว่าง) */
|
|
104
|
+
async function readTrimmed(p, max) {
|
|
77
105
|
try {
|
|
78
|
-
const
|
|
79
|
-
if (!
|
|
106
|
+
const c = (await readFile(p, 'utf8')).trim();
|
|
107
|
+
if (!c)
|
|
80
108
|
return '';
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
109
|
+
return c.length > max ? `${c.slice(0, max)}\n…` : c;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** ดึงรายการ "- ..." ใต้ "## New Candidates" จาก memory-inbox (fact ที่ remember ไว้) */
|
|
116
|
+
async function inboxCandidates(p, max) {
|
|
117
|
+
try {
|
|
118
|
+
const after = (await readFile(p, 'utf8')).split('## New Candidates')[1];
|
|
119
|
+
if (!after)
|
|
84
120
|
return '';
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
121
|
+
const lines = after
|
|
122
|
+
.split('\n')
|
|
123
|
+
.filter((l) => l.trim().startsWith('- ') && !l.includes('_('))
|
|
124
|
+
.map((l) => l.trim());
|
|
125
|
+
const text = lines.join('\n').trim();
|
|
126
|
+
return text.length > max ? `${text.slice(0, max)}\n…` : text;
|
|
88
127
|
}
|
|
89
128
|
catch {
|
|
90
129
|
return '';
|
|
91
130
|
}
|
|
92
131
|
}
|
|
93
|
-
/**
|
|
94
|
-
export async function
|
|
95
|
-
const line = `- ${fact.trim().replace(/\s+/g, ' ')}`;
|
|
96
|
-
await mkdir(AUTO_MEMORY_DIR, { recursive: true });
|
|
97
|
-
let existing = '';
|
|
132
|
+
/** path ของ second-brain vault จาก config (undefined = ไม่ได้ตั้ง) */
|
|
133
|
+
export async function getBrainPath() {
|
|
98
134
|
try {
|
|
99
|
-
|
|
135
|
+
const cfg = JSON.parse(await readFile(appHomePath('config.json'), 'utf8'));
|
|
136
|
+
return cfg.brainPath;
|
|
100
137
|
}
|
|
101
138
|
catch {
|
|
102
|
-
|
|
139
|
+
return undefined;
|
|
103
140
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* route fact เข้า vault Memory-Inbox (candidate buffer ตาม §4) — "AI เขียนลง second brain ของคุณ"
|
|
144
|
+
* เขียนเฉพาะถ้า memory-inbox.md มีจริง (กันสร้างไฟล์ใน path ที่ไม่ใช่ vault) · คืน true ถ้าเขียน
|
|
145
|
+
*/
|
|
146
|
+
export async function appendToVaultInbox(brainPath, fact) {
|
|
147
|
+
const p = join(brainPath, 'Shared', 'Memory-Inbox', 'memory-inbox.md');
|
|
148
|
+
let content;
|
|
149
|
+
try {
|
|
150
|
+
content = await readFile(p, 'utf8');
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false; // ไม่ใช่ vault ที่มีไฟล์นี้ → ไม่ route
|
|
154
|
+
}
|
|
155
|
+
const safeFact = redactKey(fact);
|
|
156
|
+
const line = `- ${safeFact.trim().replace(/\s+/g, ' ')}`;
|
|
157
|
+
if (content.includes(line))
|
|
158
|
+
return false; // dedup
|
|
159
|
+
const marker = '## New Candidates';
|
|
160
|
+
const next = content.includes(marker)
|
|
161
|
+
? content.replace(marker, `${marker}\n${line}`)
|
|
162
|
+
: `${content.trimEnd()}\n${line}\n`;
|
|
163
|
+
await writeFile(p, next);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
/** บันทึก worklog ย่อเข้า vault Sessions/ (รายวัน) — "second brain จำว่าวันนี้ทำอะไร" */
|
|
167
|
+
export async function appendBrainWorklog(brainPath, entry) {
|
|
168
|
+
if (!worklogEnabled())
|
|
169
|
+
return false;
|
|
170
|
+
const dir = join(brainPath, 'Sessions');
|
|
171
|
+
if (!(await exists(dir)))
|
|
172
|
+
return false; // ไม่ใช่ vault → ข้าม
|
|
173
|
+
const topic = entry.prompt.trim().split(/\s+/).slice(0, 6).join(' ').slice(0, 50) || 'work';
|
|
174
|
+
const file = join(dir, `${entry.today}-worklog.md`);
|
|
175
|
+
let content;
|
|
176
|
+
try {
|
|
177
|
+
content = await readFile(file, 'utf8');
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
content = `---\ntags: [session, session-log, worklog]\nnote_type: session-log\ncreated: ${entry.today}\nupdated: ${entry.today}\nparent: "[[Sessions/_Index]]"\nai_surface: history\n---\n\n# ${entry.today} — Worklog (auto by ${BRAND.cliName})\n\nup:: [[Sessions/_Index]]\n`;
|
|
181
|
+
}
|
|
182
|
+
const block = `\n## ${topic}\n- prompt: ${redactKey(entry.prompt).trim().slice(0, 200)}\n- model: ${entry.model}\n- ${redactKey(entry.summary).trim().slice(0, 300)}\n`;
|
|
183
|
+
// แทรกก่อน up:: ท้ายไฟล์ (กัน up:: หลุดไปกลาง)
|
|
184
|
+
const out = content.includes('\nup:: ')
|
|
185
|
+
? content.replace(/\nup:: .*$/s, `\n${block}\nup:: [[Sessions/_Index]]\n`)
|
|
186
|
+
: `${content.trimEnd()}\n${block}`;
|
|
187
|
+
await writeFile(file, out);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
// in-process write serializer: the AI SDK runs tool calls from one model step concurrently, so two
|
|
191
|
+
// `remember` calls in a turn would otherwise load → mergeFact → save on the SAME baseline and the
|
|
192
|
+
// last save would clobber the first (lost update). Chaining the read-modify-write makes them sequential.
|
|
193
|
+
let memWriteChain = Promise.resolve();
|
|
194
|
+
function withMemLock(fn) {
|
|
195
|
+
const run = memWriteChain.then(fn, fn); // run regardless of the prior task's outcome
|
|
196
|
+
memWriteChain = run.catch(() => { }); // a rejection must not break the chain for the next writer
|
|
197
|
+
return run;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* บันทึก fact ลง auto-memory (remember tool เรียก) — "Merge, Don't Append":
|
|
201
|
+
* โหลด store → mergeFact (ADD/UPDATE/NOOP/SUPERSEDE) → save (ถ้าไม่ใช่ no-write op)
|
|
202
|
+
* → consolidate เป็นระยะ → route เข้า vault inbox (best-effort) เหมือนเดิม
|
|
203
|
+
* read-modify-write ของ store และ vault inbox ถูก serialize ด้วย withMemLock กัน lost-update ตอน parallel remember
|
|
204
|
+
*/
|
|
205
|
+
export async function appendMemory(fact, noteType) {
|
|
206
|
+
if (!persistenceEnabled())
|
|
207
|
+
return;
|
|
208
|
+
const safeFact = redactKey(fact);
|
|
209
|
+
await withMemLock(async () => {
|
|
210
|
+
const store = await loadStore();
|
|
211
|
+
const { store: next, op } = mergeFact(store, { text: safeFact, trust: 'agent', noteType });
|
|
212
|
+
// PROTECTED_HALT = ไม่เขียน (ขัดกับ protected fact); op อื่นเขียนหมด (NOOP ก็เขียนเพราะ touch accessCount)
|
|
213
|
+
if (op !== 'PROTECTED_HALT') {
|
|
214
|
+
const toSave = maybeConsolidate(next) ? consolidate(next).store : next;
|
|
215
|
+
await saveStore(toSave);
|
|
216
|
+
}
|
|
217
|
+
// route เข้า vault second-brain ด้วย (best-effort) — ส่ง plain redacted string เหมือนเดิม
|
|
218
|
+
const brain = await getBrainPath();
|
|
219
|
+
if (brain)
|
|
220
|
+
await appendToVaultInbox(brain, safeFact).catch(() => false);
|
|
221
|
+
});
|
|
108
222
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// src/orchestrate.ts — subagent ORCHESTRATION (parallel fan-out + background).
|
|
3
|
+
//
|
|
4
|
+
// The single Task subagent (src/tools/task.ts) is one-shot and synchronous. This
|
|
5
|
+
// module adds the two missing orchestration primitives a frontier harness needs:
|
|
6
|
+
// 1. runParallel() — fan a list of subagents out concurrently with a real
|
|
7
|
+
// concurrency cap and PER-ITEM error isolation (one failure never sinks the
|
|
8
|
+
// batch), results returned in input order.
|
|
9
|
+
// 2. TaskRegistry — fire-and-forget BACKGROUND subagents: spawn() returns an id
|
|
10
|
+
// immediately, the work runs detached, and collect()/list()/cancel() let the
|
|
11
|
+
// main agent keep working and gather results later in the same session.
|
|
12
|
+
//
|
|
13
|
+
// Everything is PURE w.r.t. the actual agent: the subagent runner is INJECTED
|
|
14
|
+
// (SubagentRunner), and the clock + id generator are injectable, so the whole
|
|
15
|
+
// orchestration layer unit-tests with a fake runner — zero model calls, zero
|
|
16
|
+
// network — exactly like the search subsystem injects its fs.
|
|
17
|
+
// ============================================================================
|
|
18
|
+
const DEFAULT_CONCURRENCY = 5;
|
|
19
|
+
/**
|
|
20
|
+
* Run thunks concurrently, capped at `concurrency`, results in input order.
|
|
21
|
+
* The generic concurrency primitive both runParallel and worktree isolation use.
|
|
22
|
+
*/
|
|
23
|
+
export async function runThunks(thunks, concurrency = DEFAULT_CONCURRENCY) {
|
|
24
|
+
const cap = Math.max(1, Math.floor(concurrency));
|
|
25
|
+
const results = new Array(thunks.length);
|
|
26
|
+
let next = 0;
|
|
27
|
+
const worker = async () => {
|
|
28
|
+
for (;;) {
|
|
29
|
+
const i = next++;
|
|
30
|
+
if (i >= thunks.length)
|
|
31
|
+
return;
|
|
32
|
+
results[i] = await thunks[i]();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
await Promise.all(Array.from({ length: Math.min(cap, thunks.length) }, worker));
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
async function runOne(spec, runner, signal) {
|
|
39
|
+
try {
|
|
40
|
+
const text = await runner(spec, signal);
|
|
41
|
+
return { ok: true, description: spec.description, text };
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
return { ok: false, description: spec.description, text: '', error: e.message };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Run subagents concurrently, capped at `concurrency`, results in input order.
|
|
49
|
+
* Never rejects: a thrown subagent becomes an {ok:false,error} outcome so the
|
|
50
|
+
* caller always gets one outcome per spec.
|
|
51
|
+
*/
|
|
52
|
+
export async function runParallel(specs, runner, opts = {}) {
|
|
53
|
+
return runThunks(specs.map((s) => () => runOne(s, runner, opts.signal)), opts.concurrency ?? DEFAULT_CONCURRENCY);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* In-process registry of BACKGROUND subagents. spawn() launches detached work and
|
|
57
|
+
* returns an id; collect() awaits (optionally with a timeout so the agent can poll
|
|
58
|
+
* instead of block); cancel() aborts via the runner's AbortSignal. Lives for the
|
|
59
|
+
* process — background work dies when the CLI exits, so it is for within-session
|
|
60
|
+
* fan-out ("kick off research, keep coding, gather it later"), not durable jobs
|
|
61
|
+
* (use `schedule_task` / the gateway for those).
|
|
62
|
+
*/
|
|
63
|
+
export class TaskRegistry {
|
|
64
|
+
tasks = new Map();
|
|
65
|
+
settles = new Map();
|
|
66
|
+
controllers = new Map();
|
|
67
|
+
counter = 0;
|
|
68
|
+
now;
|
|
69
|
+
idGen;
|
|
70
|
+
constructor(opts = {}) {
|
|
71
|
+
this.now = opts.now ?? Date.now;
|
|
72
|
+
this.idGen = opts.idGen ?? (() => `t${++this.counter}`);
|
|
73
|
+
}
|
|
74
|
+
/** launch a detached subagent; returns its id immediately. */
|
|
75
|
+
spawn(spec, runner) {
|
|
76
|
+
const id = this.idGen();
|
|
77
|
+
const ac = new AbortController();
|
|
78
|
+
this.controllers.set(id, ac);
|
|
79
|
+
const rec = { id, description: spec.description, state: 'running', startedMs: this.now() };
|
|
80
|
+
this.tasks.set(id, rec);
|
|
81
|
+
const settle = (async () => {
|
|
82
|
+
try {
|
|
83
|
+
const text = await runner(spec, ac.signal);
|
|
84
|
+
const cur = this.tasks.get(id);
|
|
85
|
+
if (cur.state !== 'canceled')
|
|
86
|
+
Object.assign(cur, { state: 'done', text, endedMs: this.now() });
|
|
87
|
+
return cur;
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
const cur = this.tasks.get(id);
|
|
91
|
+
if (cur.state !== 'canceled')
|
|
92
|
+
Object.assign(cur, { state: 'error', error: e.message, endedMs: this.now() });
|
|
93
|
+
return cur;
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
this.controllers.delete(id);
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
// swallow at the source — collect() is the consumer; an uncollected reject must not crash the process
|
|
100
|
+
settle.catch(() => { });
|
|
101
|
+
this.settles.set(id, settle);
|
|
102
|
+
return id;
|
|
103
|
+
}
|
|
104
|
+
get(id) {
|
|
105
|
+
return this.tasks.get(id);
|
|
106
|
+
}
|
|
107
|
+
list() {
|
|
108
|
+
return [...this.tasks.values()];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Await a background task. With timeoutMs, resolves to the current (possibly
|
|
112
|
+
* still-running) record if it hasn't settled in time, so the caller can poll.
|
|
113
|
+
* Returns undefined for an unknown id.
|
|
114
|
+
*/
|
|
115
|
+
async collect(id, timeoutMs) {
|
|
116
|
+
const settle = this.settles.get(id);
|
|
117
|
+
if (!settle)
|
|
118
|
+
return undefined;
|
|
119
|
+
if (timeoutMs == null)
|
|
120
|
+
return settle;
|
|
121
|
+
let timer;
|
|
122
|
+
const timeout = new Promise((resolve) => {
|
|
123
|
+
timer = setTimeout(() => resolve(this.tasks.get(id)), Math.max(0, timeoutMs));
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
return await Promise.race([settle, timeout]);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
if (timer)
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** abort a running task (best-effort via its AbortSignal). Returns false if not running. */
|
|
134
|
+
cancel(id) {
|
|
135
|
+
const rec = this.tasks.get(id);
|
|
136
|
+
if (!rec || rec.state !== 'running')
|
|
137
|
+
return false;
|
|
138
|
+
this.controllers.get(id)?.abort();
|
|
139
|
+
Object.assign(rec, { state: 'canceled', endedMs: this.now() });
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
/** number of tasks still running — used to gate runaway fan-out. */
|
|
143
|
+
runningCount() {
|
|
144
|
+
let n = 0;
|
|
145
|
+
for (const r of this.tasks.values())
|
|
146
|
+
if (r.state === 'running')
|
|
147
|
+
n++;
|
|
148
|
+
return n;
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/providers/codex.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
/** เช็กว่า codex CLI ติดตั้ง + login ChatGPT แล้ว */
|
|
6
6
|
export async function detectCodex() {
|
|
7
7
|
const hasBinary = await new Promise((resolve) => {
|
|
8
|
-
const p = spawn('codex', ['--version']);
|
|
8
|
+
const p = spawn('codex', ['--version'], { shell: process.platform === 'win32' });
|
|
9
9
|
p.on('error', () => resolve(false));
|
|
10
10
|
p.on('close', (code) => resolve(code === 0));
|
|
11
11
|
});
|
|
@@ -35,7 +35,7 @@ export async function runCodex(opts) {
|
|
|
35
35
|
return new Promise((resolve, reject) => {
|
|
36
36
|
// OPENAI_API_KEY='' กัน BYOK key ของ Sanook ไป override ChatGPT login ของ codex
|
|
37
37
|
const env = { ...process.env, OPENAI_API_KEY: '' };
|
|
38
|
-
const p = spawn('codex', args, { env });
|
|
38
|
+
const p = spawn('codex', args, { env, shell: process.platform === 'win32' }); // Windows: codex.cmd
|
|
39
39
|
let finalText = '';
|
|
40
40
|
let threadId;
|
|
41
41
|
let buf = '';
|
package/dist/providers/keys.js
CHANGED
|
@@ -28,10 +28,11 @@ export function assertDirectApiKey(policy, key) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
if (policy.keyFormat && !policy.keyFormat.test(k)) {
|
|
31
|
-
|
|
31
|
+
const hint = policy.keyExample ? `ขึ้นต้นแบบ ${policy.keyExample}` : `ขึ้นต้นตาม ${policy.keyFormat.source}`;
|
|
32
|
+
throw new Error(`${policy.label}: format ของ API key ไม่ถูกต้อง — เช็ก/วางใหม่ (${hint})`);
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
/** ปิดบัง API key ในข้อความ log/error — เก็บแค่หัว 4 + ท้าย 2 ตัว */
|
|
35
36
|
export function redactKey(s) {
|
|
36
|
-
return s.replace(/\b(sk-[A-Za-z0-9_-]{6,}|AIza[A-Za-z0-9_-]{10,}|xai-[A-Za-z0-9]{10,}|gsk_[A-Za-z0-9]{10,}|[A-Za-z0-9_-]{24,})\b/g, (m) => (m.length > 8 ? `${m.slice(0, 4)}…${m.slice(-2)}` : '…'));
|
|
37
|
+
return s.replace(/\b(AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{6,}|AIza[A-Za-z0-9_-]{10,}|xai-[A-Za-z0-9]{10,}|gsk_[A-Za-z0-9]{10,}|[A-Za-z0-9_-]{24,})\b/g, (m) => (m.length > 8 ? `${m.slice(0, 4)}…${m.slice(-2)}` : '…'));
|
|
37
38
|
}
|
|
@@ -7,6 +7,7 @@ import { createMistral } from '@ai-sdk/mistral';
|
|
|
7
7
|
import { createGroq } from '@ai-sdk/groq';
|
|
8
8
|
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
9
9
|
import { resolveKeyFromEnv, assertDirectApiKey } from './keys.js';
|
|
10
|
+
import { BRAND } from '../brand.js';
|
|
10
11
|
// ────────────────────────────────────────────────────────────────────────────
|
|
11
12
|
// PROVIDER TABLE — เพิ่มค่าย = เพิ่ม 1 entry (loop/cost/keys ไม่ต้องแตะ)
|
|
12
13
|
// auth/format/OAuth-reject verify มิ.ย. 2026 (ดู Research/provider-connect-matrix)
|
|
@@ -20,6 +21,7 @@ export const PROVIDERS = {
|
|
|
20
21
|
baseURL: 'https://api.anthropic.com/v1',
|
|
21
22
|
requiresKey: true,
|
|
22
23
|
keyFormat: /^sk-ant-api\d{2}-/,
|
|
24
|
+
keyExample: 'sk-ant-…', // สั้นพอไม่โดน redactKey (sk- + ≥6 chars ถึงโดนตัด)
|
|
23
25
|
oauthRejectPrefixes: ['sk-ant-oat'], // Claude.ai subscription OAuth → banned
|
|
24
26
|
models: {
|
|
25
27
|
default: 'claude-opus-4-8',
|
|
@@ -39,6 +41,7 @@ export const PROVIDERS = {
|
|
|
39
41
|
envFallbacks: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'],
|
|
40
42
|
requiresKey: true,
|
|
41
43
|
keyFormat: /^AIza[0-9A-Za-z_-]{35}$/,
|
|
44
|
+
keyExample: 'AIza…',
|
|
42
45
|
oauthRejectPrefixes: ['ya29.', 'AQ.'], // Google OAuth / restricted token → banned
|
|
43
46
|
models: {
|
|
44
47
|
default: 'gemini-2.5-pro',
|
|
@@ -57,6 +60,7 @@ export const PROVIDERS = {
|
|
|
57
60
|
baseURL: 'https://api.openai.com/v1',
|
|
58
61
|
requiresKey: true,
|
|
59
62
|
keyFormat: /^sk-/,
|
|
63
|
+
keyExample: 'sk-…',
|
|
60
64
|
models: {
|
|
61
65
|
default: 'gpt-5.5',
|
|
62
66
|
smart: 'gpt-5.5',
|
|
@@ -83,6 +87,7 @@ export const PROVIDERS = {
|
|
|
83
87
|
envVar: 'XAI_API_KEY',
|
|
84
88
|
requiresKey: true,
|
|
85
89
|
keyFormat: /^xai-[A-Za-z0-9]{16,}$/,
|
|
90
|
+
keyExample: 'xai-…',
|
|
86
91
|
// grok-4 (snapshot grok-4-0709) retired 2026-05-15 → redirect grok-4.3 (doc audit มิ.ย. 2026)
|
|
87
92
|
models: { default: 'grok-4.3', smart: 'grok-4.3', grok: 'grok-4.3' },
|
|
88
93
|
create: (key) => createXai({ apiKey: key }),
|
|
@@ -102,6 +107,7 @@ export const PROVIDERS = {
|
|
|
102
107
|
envVar: 'GROQ_API_KEY',
|
|
103
108
|
requiresKey: true,
|
|
104
109
|
keyFormat: /^gsk_[A-Za-z0-9]{20,}$/,
|
|
110
|
+
keyExample: 'gsk_…',
|
|
105
111
|
models: { default: 'llama-3.3-70b-versatile', fast: 'llama-3.3-70b-versatile' },
|
|
106
112
|
create: (key) => createGroq({ apiKey: key }),
|
|
107
113
|
},
|
|
@@ -215,6 +221,43 @@ export function specKey(spec) {
|
|
|
215
221
|
const { provider, model } = parseSpec(spec);
|
|
216
222
|
return `${provider}:${model}`;
|
|
217
223
|
}
|
|
224
|
+
/** หน้า console ที่ใช้สร้าง API key ต่อ provider — โชว์ในข้อความ error/wizard ("ไปเอา key ที่ไหน") */
|
|
225
|
+
const CONSOLE_URLS = {
|
|
226
|
+
anthropic: 'https://console.anthropic.com/settings/keys',
|
|
227
|
+
google: 'https://aistudio.google.com/apikey',
|
|
228
|
+
openai: 'https://platform.openai.com/api-keys',
|
|
229
|
+
deepseek: 'https://platform.deepseek.com/api_keys',
|
|
230
|
+
xai: 'https://console.x.ai',
|
|
231
|
+
mistral: 'https://console.mistral.ai/api-keys',
|
|
232
|
+
groq: 'https://console.groq.com/keys',
|
|
233
|
+
minimax: 'https://platform.minimax.io',
|
|
234
|
+
glm: 'https://z.ai/manage-apikey/apikey-list',
|
|
235
|
+
};
|
|
236
|
+
export function consoleUrl(provider) {
|
|
237
|
+
return CONSOLE_URLS[provider];
|
|
238
|
+
}
|
|
239
|
+
/** หา provider ที่ "มี key ใน env แล้ว" (cloud, ตามลำดับนิยม) — ใช้ทำ first-run smart skip + แนะ headless */
|
|
240
|
+
export function detectEnvProvider() {
|
|
241
|
+
for (const id of ['anthropic', 'openai', 'google', 'deepseek', 'xai', 'mistral', 'groq', 'glm', 'minimax']) {
|
|
242
|
+
const cfg = PROVIDERS[id];
|
|
243
|
+
if (cfg?.requiresKey && resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks)) {
|
|
244
|
+
return { provider: id, label: cfg.label, envVar: cfg.envVar, model: cfg.models.default };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* model ที่ "ถูก/เร็วกว่า" ในค่ายเดียวกับ spec (สำหรับงานกลไก เช่น summarize/compaction) —
|
|
251
|
+
* ใช้ key เดียวกัน ไม่ต้องตั้ง key ใหม่. ไม่มี fast tier → คืน spec เดิม (ทำงานได้แต่ไม่ประหยัด)
|
|
252
|
+
*/
|
|
253
|
+
export function fastSibling(spec) {
|
|
254
|
+
const { provider } = parseSpec(spec);
|
|
255
|
+
const cfg = PROVIDERS[provider];
|
|
256
|
+
if (!cfg)
|
|
257
|
+
return spec;
|
|
258
|
+
const fast = cfg.models.fast ?? cfg.models.flash ?? cfg.models.haiku ?? cfg.models.air;
|
|
259
|
+
return fast ? `${provider}:${fast}` : spec;
|
|
260
|
+
}
|
|
218
261
|
/** resolve spec → LanguageModel (throw ถ้าไม่มี key / provider ผิด / key เป็น OAuth) */
|
|
219
262
|
export function resolveModel(spec) {
|
|
220
263
|
const { provider, model } = parseSpec(spec);
|
|
@@ -226,7 +269,11 @@ export function resolveModel(spec) {
|
|
|
226
269
|
if (cfg.requiresKey) {
|
|
227
270
|
const found = resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks);
|
|
228
271
|
if (!found) {
|
|
229
|
-
|
|
272
|
+
const url = consoleUrl(provider);
|
|
273
|
+
throw new Error(`ยังไม่มี API key ของ ${cfg.label} (${cfg.envVar})\n` +
|
|
274
|
+
(url ? ` • เอา key ที่: ${url}\n` : '') +
|
|
275
|
+
` • ตั้ง: export ${cfg.envVar}="..." ` +
|
|
276
|
+
`หรือรัน \`${BRAND.cliName}\` (ไม่ใส่ task) เพื่อ setup wizard`);
|
|
230
277
|
}
|
|
231
278
|
assertDirectApiKey(cfg, found); // reject OAuth/subscription token + format ผิด
|
|
232
279
|
key = found;
|
|
@@ -239,3 +286,88 @@ export function resolveModel(spec) {
|
|
|
239
286
|
(cfg.requiresKey ? cfg.baseURL : process.env[cfg.envVar] ?? cfg.baseURL);
|
|
240
287
|
return cfg.create(key, baseURL)(model);
|
|
241
288
|
}
|
|
289
|
+
export const EMBEDDING_PROVIDERS = {
|
|
290
|
+
openai: {
|
|
291
|
+
envVar: 'OPENAI_API_KEY',
|
|
292
|
+
requiresKey: true,
|
|
293
|
+
defaultModel: 'text-embedding-3-small',
|
|
294
|
+
create: (key, baseURL) => (id) => createOpenAI({ apiKey: key, baseURL }).textEmbeddingModel(id),
|
|
295
|
+
},
|
|
296
|
+
mistral: {
|
|
297
|
+
envVar: 'MISTRAL_API_KEY',
|
|
298
|
+
requiresKey: true,
|
|
299
|
+
defaultModel: 'mistral-embed',
|
|
300
|
+
create: (key) => (id) => createMistral({ apiKey: key }).textEmbeddingModel(id),
|
|
301
|
+
},
|
|
302
|
+
google: {
|
|
303
|
+
envVar: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
|
304
|
+
envFallbacks: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'],
|
|
305
|
+
requiresKey: true,
|
|
306
|
+
defaultModel: 'text-embedding-004',
|
|
307
|
+
create: (key) => (id) => createGoogleGenerativeAI({ apiKey: key }).textEmbeddingModel(id),
|
|
308
|
+
},
|
|
309
|
+
// local — only picked when explicitly requested (auto-detect never assumes a server is up)
|
|
310
|
+
ollama: {
|
|
311
|
+
envVar: 'OLLAMA_BASE_URL',
|
|
312
|
+
requiresKey: false,
|
|
313
|
+
localPlaceholderKey: 'ollama',
|
|
314
|
+
defaultModel: 'nomic-embed-text',
|
|
315
|
+
create: (key, baseURL) => (id) => createOpenAICompatible({ name: 'ollama', apiKey: key, baseURL: baseURL ?? 'http://localhost:11434/v1' }).textEmbeddingModel(id),
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
/** cloud, key-gated providers tried (in order) when no explicit embeddingModel is configured. */
|
|
319
|
+
const EMBED_AUTODETECT = ['openai', 'mistral', 'google'];
|
|
320
|
+
function buildEmbedder(provider, modelId) {
|
|
321
|
+
const cfg = EMBEDDING_PROVIDERS[provider];
|
|
322
|
+
if (!cfg)
|
|
323
|
+
return null;
|
|
324
|
+
let key;
|
|
325
|
+
if (cfg.requiresKey) {
|
|
326
|
+
const found = resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks);
|
|
327
|
+
if (!found)
|
|
328
|
+
return null;
|
|
329
|
+
const policy = PROVIDERS[provider];
|
|
330
|
+
if (policy) {
|
|
331
|
+
try {
|
|
332
|
+
assertDirectApiKey(policy, found);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
key = found;
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
key = resolveKeyFromEnv(cfg.envVar) ?? cfg.localPlaceholderKey ?? 'local';
|
|
342
|
+
}
|
|
343
|
+
const baseURL = process.env[`${provider.toUpperCase()}_BASE_URL`] ??
|
|
344
|
+
(cfg.requiresKey ? undefined : process.env[cfg.envVar] ?? undefined);
|
|
345
|
+
const id = modelId ?? cfg.defaultModel;
|
|
346
|
+
try {
|
|
347
|
+
return { model: cfg.create(key, baseURL)(id), provider, modelId: id, tag: `${provider}:${id}` };
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Resolve an embeddings model. `spec` is 'provider' | 'provider:modelId' | undefined.
|
|
355
|
+
* undefined → auto-detect the first cloud provider whose key is present. Returns null
|
|
356
|
+
* (never throws) when nothing resolves, so callers degrade to BM25-only.
|
|
357
|
+
*/
|
|
358
|
+
export function resolveEmbedder(spec) {
|
|
359
|
+
if (spec) {
|
|
360
|
+
const idx = spec.indexOf(':');
|
|
361
|
+
const provider = (idx === -1 ? spec : spec.slice(0, idx)).trim();
|
|
362
|
+
if (!provider)
|
|
363
|
+
return null;
|
|
364
|
+
const modelId = idx === -1 ? undefined : spec.slice(idx + 1).trim() || undefined;
|
|
365
|
+
return buildEmbedder(provider, modelId);
|
|
366
|
+
}
|
|
367
|
+
for (const provider of EMBED_AUTODETECT) {
|
|
368
|
+
const e = buildEmbedder(provider);
|
|
369
|
+
if (e)
|
|
370
|
+
return e;
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|