@asobacloud/nehanda 0.7.0 → 0.9.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/README.md +52 -19
- package/bin/agent.mjs +51 -37
- package/bin/nehanda-ui.mjs +256 -79
- package/lib/configureProvider.mjs +4 -3
- package/lib/explore.mjs +109 -0
- package/lib/issues.mjs +144 -0
- package/lib/laya_sidecar.mjs +11 -5
- package/lib/modelPicker.mjs +124 -0
- package/lib/openaiCompat.mjs +17 -1
- package/lib/orchestrate.mjs +160 -23
- package/lib/scripts/laya_worker.py +7 -2
- package/lib/slashCommands.mjs +128 -0
- package/lib/store.mjs +8 -0
- package/lib/ui.mjs +24 -7
- package/lib/workflow.mjs +18 -6
- package/package.json +1 -1
- package/scripts/build-artifacts.sh +81 -0
- package/scripts/install.sh +51 -0
- package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
package/lib/explore.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// Directories to skip entirely during exploration
|
|
5
|
+
const SKIP_DIRS = new Set([
|
|
6
|
+
'node_modules', '.git', '.issues', 'dist', 'build', 'tmp', '.tmp',
|
|
7
|
+
'coverage', '.nyc_output', '__pycache__', '.venv', 'venv',
|
|
8
|
+
'.next', '.nuxt', 'out',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
// Documentation files receive highest priority (read first)
|
|
12
|
+
const DOC_FILENAMES = new Set([
|
|
13
|
+
'README.md', 'readme.md', 'README', 'readme',
|
|
14
|
+
'CONTRIBUTING.md', 'CONTRIBUTING', 'ARCHITECTURE.md', 'ARCHITECTURE',
|
|
15
|
+
'AGENTS.md', 'GEMINI.md', 'CHANGELOG.md', 'CHANGES.md',
|
|
16
|
+
'DESIGN.md', 'OVERVIEW.md', 'ONBOARDING.md', 'SETUP.md',
|
|
17
|
+
'MAP.md', 'RUNBOOK.md', 'OPERATIONS.md', 'SPECIFICATION.md', 'SPEC.md',
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
// Secondary documentation: anything under docs/ or .docs/
|
|
21
|
+
const DOC_DIRS = new Set(['docs', '.docs', 'documentation', 'doc'])
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Scans a repository directory and returns files in documentation-first order.
|
|
25
|
+
*
|
|
26
|
+
* Priority order:
|
|
27
|
+
* 1. Root-level named documentation files (README.md, CONTRIBUTING.md, etc.)
|
|
28
|
+
* 2. All Markdown files inside docs/ subdirectories
|
|
29
|
+
* 3. Root-level *.md files not already listed
|
|
30
|
+
* 4. Configuration files (package.json, pyproject.toml, etc.)
|
|
31
|
+
* 5. All other source files (alphabetical, depth-first)
|
|
32
|
+
*
|
|
33
|
+
* @param {string} cwd - The repository root to scan
|
|
34
|
+
* @returns {string[]} - Absolute paths in exploration priority order
|
|
35
|
+
*/
|
|
36
|
+
export function getDocPrioritizedFiles(cwd) {
|
|
37
|
+
const abs = path.resolve(cwd)
|
|
38
|
+
|
|
39
|
+
// Tier 1: High-priority named root documentation files
|
|
40
|
+
const tier1 = []
|
|
41
|
+
for (const name of DOC_FILENAMES) {
|
|
42
|
+
const f = path.join(abs, name)
|
|
43
|
+
if (fs.existsSync(f) && fs.statSync(f).isFile()) {
|
|
44
|
+
tier1.push(f)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Collect all files in the repo (excluding SKIP_DIRS)
|
|
49
|
+
const allFiles = []
|
|
50
|
+
function walk(dir) {
|
|
51
|
+
let entries
|
|
52
|
+
try {
|
|
53
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
54
|
+
} catch {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
if (!SKIP_DIRS.has(entry.name)) walk(path.join(dir, entry.name))
|
|
60
|
+
} else if (entry.isFile()) {
|
|
61
|
+
allFiles.push(path.join(dir, entry.name))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
walk(abs)
|
|
66
|
+
|
|
67
|
+
const tier1Set = new Set(tier1)
|
|
68
|
+
|
|
69
|
+
// Tier 2: Markdown files inside docs/ directories
|
|
70
|
+
const tier2 = allFiles.filter(f => {
|
|
71
|
+
if (tier1Set.has(f)) return false
|
|
72
|
+
const rel = path.relative(abs, f)
|
|
73
|
+
const parts = rel.split(path.sep)
|
|
74
|
+
return parts.length > 1 && DOC_DIRS.has(parts[0]) && f.endsWith('.md')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const tier2Set = new Set(tier2)
|
|
78
|
+
|
|
79
|
+
// Tier 3: Remaining root-level *.md files
|
|
80
|
+
const tier3 = allFiles.filter(f => {
|
|
81
|
+
if (tier1Set.has(f) || tier2Set.has(f)) return false
|
|
82
|
+
const rel = path.relative(abs, f)
|
|
83
|
+
return !rel.includes(path.sep) && f.endsWith('.md')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const tier3Set = new Set(tier3)
|
|
87
|
+
|
|
88
|
+
// Tier 4: Config files
|
|
89
|
+
const CONFIG_NAMES = new Set([
|
|
90
|
+
'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
91
|
+
'pyproject.toml', 'setup.py', 'setup.cfg', 'Cargo.toml',
|
|
92
|
+
'.env.example', '.env.sample', 'tsconfig.json', 'jest.config.js',
|
|
93
|
+
'jest.config.mjs', 'eslint.config.js', '.eslintrc.js', '.eslintrc.json',
|
|
94
|
+
])
|
|
95
|
+
const tier4 = allFiles.filter(f => {
|
|
96
|
+
if (tier1Set.has(f) || tier2Set.has(f) || tier3Set.has(f)) return false
|
|
97
|
+
const base = path.basename(f)
|
|
98
|
+
return CONFIG_NAMES.has(base)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const tier4Set = new Set(tier4)
|
|
102
|
+
|
|
103
|
+
// Tier 5: Everything else
|
|
104
|
+
const tier5 = allFiles.filter(f =>
|
|
105
|
+
!tier1Set.has(f) && !tier2Set.has(f) && !tier3Set.has(f) && !tier4Set.has(f)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return [...tier1, ...tier2, ...tier3, ...tier4, ...tier5]
|
|
109
|
+
}
|
package/lib/issues.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
function slugify(title) {
|
|
7
|
+
return title
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
10
|
+
.replace(/^-+|-+$/g, '')
|
|
11
|
+
.slice(0, 60)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ─── Core API ─────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/** Returns the /.issues/ directory path for the given repo root, creating it if needed. */
|
|
17
|
+
export function getIssuesDir(cwd) {
|
|
18
|
+
const dir = path.join(cwd, '.issues')
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
20
|
+
return dir
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Returns the next consecutive issue number by scanning existing /.issues/ files. */
|
|
24
|
+
export function getNextIssueNumber(cwd) {
|
|
25
|
+
const dir = getIssuesDir(cwd)
|
|
26
|
+
const files = fs.readdirSync(dir).filter(f => /^\d+/.test(f))
|
|
27
|
+
if (files.length === 0) return 1
|
|
28
|
+
const numbers = files.map(f => {
|
|
29
|
+
const m = f.match(/^(\d+)/)
|
|
30
|
+
return m ? parseInt(m[1], 10) : 0
|
|
31
|
+
})
|
|
32
|
+
return Math.max(...numbers) + 1
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates a consecutively numbered structured markdown issue in /.issues/.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} cwd - Repository root directory
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* @param {string} opts.title - Short issue title
|
|
41
|
+
* @param {string} opts.observed - Observed (broken) behavior
|
|
42
|
+
* @param {string} opts.expected - Expected (desired) behavior
|
|
43
|
+
* @param {string} opts.diagnosis - Evidence-backed root cause
|
|
44
|
+
* @param {string[]} opts.acceptanceCriteria - Array of AC strings (e.g. 'AC-1: ...', 'AC-2: ...')
|
|
45
|
+
* @returns {{ number: number, file: string, slug: string }}
|
|
46
|
+
*/
|
|
47
|
+
export function createIssue(cwd, { title, observed, expected, diagnosis, acceptanceCriteria = [] }) {
|
|
48
|
+
const num = getNextIssueNumber(cwd)
|
|
49
|
+
const numStr = String(num).padStart(4, '0')
|
|
50
|
+
const slug = slugify(title)
|
|
51
|
+
const filename = `${numStr}-${slug}.md`
|
|
52
|
+
const dir = getIssuesDir(cwd)
|
|
53
|
+
const file = path.join(dir, filename)
|
|
54
|
+
|
|
55
|
+
const acLines = acceptanceCriteria
|
|
56
|
+
.map(ac => `- [ ] ${ac}`)
|
|
57
|
+
.join('\n')
|
|
58
|
+
|
|
59
|
+
const created = new Date().toISOString()
|
|
60
|
+
|
|
61
|
+
const content = `# Issue #${numStr}: ${title}
|
|
62
|
+
Status: open
|
|
63
|
+
Created: ${created}
|
|
64
|
+
|
|
65
|
+
## Observed Behavior
|
|
66
|
+
${observed}
|
|
67
|
+
|
|
68
|
+
## Expected Behavior
|
|
69
|
+
${expected}
|
|
70
|
+
|
|
71
|
+
## Evidenced Diagnosis
|
|
72
|
+
${diagnosis}
|
|
73
|
+
|
|
74
|
+
## Acceptance Criteria
|
|
75
|
+
${acLines || '- [ ] AC-1: (define acceptance criteria)'}
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
fs.writeFileSync(file, content, 'utf8')
|
|
79
|
+
return { number: num, file, slug }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Closes an open issue by updating its status and checking all AC checkboxes.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} cwd - Repository root directory
|
|
86
|
+
* @param {number} issueNumber - The issue number to close
|
|
87
|
+
* @param {{ commitHash?: string }} [opts]
|
|
88
|
+
*/
|
|
89
|
+
export function closeIssue(cwd, issueNumber, opts = {}) {
|
|
90
|
+
const dir = getIssuesDir(cwd)
|
|
91
|
+
const numStr = String(issueNumber).padStart(4, '0')
|
|
92
|
+
const padded = new RegExp(`^0*${issueNumber}-.*\\.md$`)
|
|
93
|
+
const files = fs.readdirSync(dir).filter(f => padded.test(f) || f.startsWith(numStr + '-'))
|
|
94
|
+
if (files.length === 0) {
|
|
95
|
+
throw new Error(`Issue #${issueNumber} not found in ${dir}`)
|
|
96
|
+
}
|
|
97
|
+
const file = path.join(dir, files[0])
|
|
98
|
+
let content = fs.readFileSync(file, 'utf8')
|
|
99
|
+
|
|
100
|
+
// Update status
|
|
101
|
+
content = content.replace(/^Status: open$/m, 'Status: closed')
|
|
102
|
+
|
|
103
|
+
// Optionally record closed timestamp
|
|
104
|
+
if (opts.commitHash) {
|
|
105
|
+
content = content.replace(
|
|
106
|
+
/^Status: closed$/m,
|
|
107
|
+
`Status: closed\nClosed: ${new Date().toISOString()} (${opts.commitHash})`
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check all unchecked boxes
|
|
112
|
+
content = content.replace(/^- \[ \]/gm, '- [x]')
|
|
113
|
+
|
|
114
|
+
fs.writeFileSync(file, content, 'utf8')
|
|
115
|
+
return { file, number: issueNumber }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Reads and returns metadata from an issue file.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} cwd - Repository root directory
|
|
122
|
+
* @param {number} issueNumber
|
|
123
|
+
* @returns {{ number: number, file: string, status: string, title: string, content: string } | null}
|
|
124
|
+
*/
|
|
125
|
+
export function getIssue(cwd, issueNumber) {
|
|
126
|
+
const dir = getIssuesDir(cwd)
|
|
127
|
+
const numStr = String(issueNumber).padStart(4, '0')
|
|
128
|
+
const files = fs.readdirSync(dir).filter(f => f.startsWith(numStr + '-'))
|
|
129
|
+
if (files.length === 0) return null
|
|
130
|
+
|
|
131
|
+
const file = path.join(dir, files[0])
|
|
132
|
+
const content = fs.readFileSync(file, 'utf8')
|
|
133
|
+
|
|
134
|
+
const titleMatch = content.match(/^# Issue #\d+: (.+)$/m)
|
|
135
|
+
const statusMatch = content.match(/^Status: (\w+)$/m)
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
number: issueNumber,
|
|
139
|
+
file,
|
|
140
|
+
title: titleMatch ? titleMatch[1] : '',
|
|
141
|
+
status: statusMatch ? statusMatch[1] : 'unknown',
|
|
142
|
+
content,
|
|
143
|
+
}
|
|
144
|
+
}
|
package/lib/laya_sidecar.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
6
6
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
7
|
const WORKER_PATH = path.resolve(__dirname, 'scripts', 'laya_worker.py')
|
|
8
8
|
const ENSURE_SCRIPT = path.resolve(__dirname, 'scripts', 'ensure-laya-env.sh')
|
|
9
|
-
const DEFAULT_TIMEOUT_MS =
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = Number(process.env.LAYA_TIMEOUT_MS) || 250
|
|
10
10
|
const NEHANDA_HOME = process.env.NEHANDA_HOME || path.join(process.env.HOME || '/tmp', '.nehanda')
|
|
11
11
|
const VENV_PYTHON = path.join(NEHANDA_HOME, 'venv', 'bin', 'python')
|
|
12
12
|
const LAYA_READY_MARKER = path.join(NEHANDA_HOME, '.laya-ready')
|
|
@@ -135,12 +135,12 @@ export async function ensureLayaReady() {
|
|
|
135
135
|
throw _provisionError
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
// Warm the sidecar: spawn worker + send a real
|
|
138
|
+
// Warm the sidecar: spawn worker + send a real load so the checkpoint
|
|
139
139
|
// loads into memory before the first user turn.
|
|
140
140
|
process.stderr.write('[laya-sidecar] warming Laya sidecar...\n')
|
|
141
|
-
const warm = await
|
|
142
|
-
if (!warm.ok) {
|
|
143
|
-
const msg = `[laya-sidecar] Laya warm-up failed: ${warm.error}`
|
|
141
|
+
const warm = await loadLaya(60_000)
|
|
142
|
+
if (!warm.ok || !warm.result?.loaded) {
|
|
143
|
+
const msg = `[laya-sidecar] Laya warm-up failed: ${warm.error || 'checkpoint load failed'}`
|
|
144
144
|
process.stderr.write(msg + '\n')
|
|
145
145
|
_provisionError = new Error(msg)
|
|
146
146
|
throw _provisionError
|
|
@@ -177,6 +177,12 @@ export function layaRequest(method, params = {}, timeoutMs = DEFAULT_TIMEOUT_MS)
|
|
|
177
177
|
})
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/** Warm load (loads checkpoint weights into memory/MPS). */
|
|
181
|
+
export async function loadLaya(timeoutMs = 60_000) {
|
|
182
|
+
restartCount = 0
|
|
183
|
+
return layaRequest('load', {}, timeoutMs)
|
|
184
|
+
}
|
|
185
|
+
|
|
180
186
|
/** Warm ping (longer timeout for cold start). */
|
|
181
187
|
export async function pingLaya(timeoutMs = 30_000) {
|
|
182
188
|
restartCount = 0
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure navigation logic for the /model wizard's large model picker (#10).
|
|
3
|
+
*
|
|
4
|
+
* Grouping, filtering, windowing (HP-2) and ask-prompt identity (HP-1a) live
|
|
5
|
+
* here as pure functions so they are directly testable. The React wiring is
|
|
6
|
+
* in bin/nehanda-ui.mjs (ModelPicker component / bridge.ask).
|
|
7
|
+
*
|
|
8
|
+
* HP-1a: every wizard question gets a unique ask id used as the React key for
|
|
9
|
+
* AskPrompt, forcing a remount between questions so the typed value of
|
|
10
|
+
* the previous question (e.g. an API key) can never carry into the
|
|
11
|
+
* next prompt's input field.
|
|
12
|
+
* HP-2: discovered lists longer than PICKER_THRESHOLD render in a picker with
|
|
13
|
+
* vendor groups, type-to-filter search, and a centered window.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Select lists longer than this switch to the grouped/filterable picker. */
|
|
17
|
+
export const PICKER_THRESHOLD = 12
|
|
18
|
+
|
|
19
|
+
/** Maximum rows rendered at once in browse/search views. */
|
|
20
|
+
export const PICKER_WINDOW = 12
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Monotonic per-question id source for AskPrompt React keys (HP-1a).
|
|
24
|
+
* Distinct ids across consecutive questions ⇒ AskPrompt remounts ⇒ value
|
|
25
|
+
* state resets to '' for every new prompt.
|
|
26
|
+
*/
|
|
27
|
+
export function createAskIdSequencer() {
|
|
28
|
+
let n = 0
|
|
29
|
+
return {
|
|
30
|
+
next() { return ++n },
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Vendor prefix of a discovered model id.
|
|
36
|
+
* OpenRouter style `vendor/model` → `vendor`; Ollama style `vendor:tag` →
|
|
37
|
+
* `vendor`; separator-less ids are their own single-model group.
|
|
38
|
+
* @param {string} name
|
|
39
|
+
*/
|
|
40
|
+
export function vendorOfModelId(name) {
|
|
41
|
+
const s = String(name ?? '')
|
|
42
|
+
const sep = s.includes('/') ? s.indexOf('/') : s.indexOf(':')
|
|
43
|
+
return sep === -1 ? s : s.slice(0, sep)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Group discovered model items by vendor (HP-2).
|
|
48
|
+
* Groups sort by size desc, then vendor name; the `__custom__` entry is
|
|
49
|
+
* always pinned last so it is reachable without scrolling.
|
|
50
|
+
* @param {{ id: string, name?: string, label?: string, desc?: string }[]} items
|
|
51
|
+
*/
|
|
52
|
+
export function groupModelsByVendor(items) {
|
|
53
|
+
const custom = items.find(i => i && i.id === '__custom__') || {
|
|
54
|
+
id: '__custom__',
|
|
55
|
+
label: '(enter custom name…)',
|
|
56
|
+
desc: 'Type a model id not listed above',
|
|
57
|
+
}
|
|
58
|
+
const real = items.filter(i => i && i.id !== '__custom__')
|
|
59
|
+
const byVendor = new Map()
|
|
60
|
+
for (const it of real) {
|
|
61
|
+
const vendor = vendorOfModelId(it.name ?? it.label ?? it.id ?? '')
|
|
62
|
+
if (!vendor) continue
|
|
63
|
+
if (!byVendor.has(vendor)) byVendor.set(vendor, [])
|
|
64
|
+
byVendor.get(vendor).push(it)
|
|
65
|
+
}
|
|
66
|
+
const groups = [...byVendor.entries()].map(([vendor, models]) => ({
|
|
67
|
+
id: vendor,
|
|
68
|
+
label: vendor,
|
|
69
|
+
desc: `${models.length} model${models.length === 1 ? '' : 's'}`,
|
|
70
|
+
models,
|
|
71
|
+
isCustom: false,
|
|
72
|
+
}))
|
|
73
|
+
groups.sort((a, b) => (b.models.length - a.models.length) || a.id.localeCompare(b.id))
|
|
74
|
+
groups.push({
|
|
75
|
+
id: '__custom__',
|
|
76
|
+
label: custom.label || '(enter custom name…)',
|
|
77
|
+
desc: 'Type a model id not listed above',
|
|
78
|
+
models: [custom],
|
|
79
|
+
isCustom: true,
|
|
80
|
+
})
|
|
81
|
+
return groups
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Case-insensitive substring filter for type-to-search (HP-2).
|
|
86
|
+
* The `__custom__` entry is always pinned last, even with no matches, so the
|
|
87
|
+
* user can always fall back to typing a custom model id.
|
|
88
|
+
* @param {{ id: string, name?: string, label?: string }[]} items
|
|
89
|
+
* @param {string} query
|
|
90
|
+
*/
|
|
91
|
+
export function filterModelItems(items, query) {
|
|
92
|
+
const q = String(query ?? '').trim().toLowerCase()
|
|
93
|
+
const custom = items.find(i => i && i.id === '__custom__')
|
|
94
|
+
const real = items.filter(i => i && i.id !== '__custom__')
|
|
95
|
+
const matched = q
|
|
96
|
+
? real.filter(i => String(i.name ?? i.label ?? '').toLowerCase().includes(q))
|
|
97
|
+
: real
|
|
98
|
+
return custom ? [...matched, custom] : matched
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Centered window over a list for windowed rendering (HP-2).
|
|
103
|
+
* Keeps the cursor near the middle once the list overflows the window and
|
|
104
|
+
* reports hidden rows above/below for "…N more" hints.
|
|
105
|
+
* @param {any[]} list
|
|
106
|
+
* @param {number} cursor
|
|
107
|
+
* @param {number} [max] window size (default PICKER_WINDOW)
|
|
108
|
+
*/
|
|
109
|
+
export function visibleWindow(list, cursor, max = PICKER_WINDOW) {
|
|
110
|
+
const len = list.length
|
|
111
|
+
if (len <= max) {
|
|
112
|
+
return { start: 0, visible: list, hiddenAbove: 0, hiddenBelow: 0 }
|
|
113
|
+
}
|
|
114
|
+
const half = Math.floor(max / 2)
|
|
115
|
+
let start = cursor - half
|
|
116
|
+
if (start < 0) start = 0
|
|
117
|
+
if (start > len - max) start = len - max
|
|
118
|
+
return {
|
|
119
|
+
start,
|
|
120
|
+
visible: list.slice(start, start + max),
|
|
121
|
+
hiddenAbove: start,
|
|
122
|
+
hiddenBelow: len - (start + max),
|
|
123
|
+
}
|
|
124
|
+
}
|
package/lib/openaiCompat.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function isAbortError(e) {
|
|
|
37
37
|
return msg === 'Request was aborted.' || /operation was aborted/i.test(msg)
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messages, tools, io, maxTokens = 8192, bufferOutput = true, num_ctx: numCtx, omitToolChoice: skipToolChoice = false, useStream = true, signal } = {}) {
|
|
40
|
+
export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messages, tools, io, maxTokens = 8192, bufferOutput = true, num_ctx: numCtx, omitToolChoice: skipToolChoice = false, useStream = true, signal, think } = {}) {
|
|
41
41
|
const url = `${String(baseUrl).replace(/\/$/, '')}/chat/completions`
|
|
42
42
|
const body = { model, messages, stream: useStream, max_tokens: maxTokens }
|
|
43
43
|
// Suppress tool_choice:"auto" for providers that do not support it (e.g. Nehanda
|
|
@@ -45,6 +45,8 @@ export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messa
|
|
|
45
45
|
// send the tools schema so the model can call them — we just omit the directive.
|
|
46
46
|
if (tools?.length) { body.tools = tools; if (!skipToolChoice) body.tool_choice = 'auto' }
|
|
47
47
|
if (numCtx) body.num_ctx = numCtx
|
|
48
|
+
// #22: Ollama think:false disables chain-of-thought for models that support it
|
|
49
|
+
if (think === false) body.think = false
|
|
48
50
|
|
|
49
51
|
const res = await fetch(url, {
|
|
50
52
|
method: 'POST',
|
|
@@ -75,6 +77,10 @@ export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messa
|
|
|
75
77
|
assistantBlocks,
|
|
76
78
|
finishReason: json.choices?.[0]?.finish_reason || 'stop',
|
|
77
79
|
bufferedText: bufferOutput ? strippedText : null,
|
|
80
|
+
// #11: preserve pre-strip content so an empty post-strip result stays
|
|
81
|
+
// diagnosable instead of being indistinguishable from "no response".
|
|
82
|
+
rawText: content,
|
|
83
|
+
rawLength: content?.length ?? 0,
|
|
78
84
|
usage: json.usage || null,
|
|
79
85
|
}
|
|
80
86
|
}
|
|
@@ -82,6 +88,7 @@ export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messa
|
|
|
82
88
|
const reader = res.body.getReader()
|
|
83
89
|
const decoder = new TextDecoder()
|
|
84
90
|
let buffer = '', fullText = '', finishReason = null, firstToken = true, usage = null
|
|
91
|
+
let reasoningTokenCount = 0 // #22: live reasoning token count for spinner updates
|
|
85
92
|
const toolAcc = new Map()
|
|
86
93
|
|
|
87
94
|
while (true) {
|
|
@@ -112,6 +119,15 @@ export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messa
|
|
|
112
119
|
io.write(delta.content)
|
|
113
120
|
}
|
|
114
121
|
}
|
|
122
|
+
// #22: reasoning tokens (Ollama delta.reasoning / delta.reasoning_content) —
|
|
123
|
+
// update spinner with live token count; never include in output text.
|
|
124
|
+
const reasoningChunk = delta?.reasoning ?? delta?.reasoning_content
|
|
125
|
+
if (reasoningChunk) {
|
|
126
|
+
reasoningTokenCount += reasoningChunk.split(/\s+/).filter(Boolean).length
|
|
127
|
+
if (io?.spinner?.update) {
|
|
128
|
+
io.spinner.update(`Thinking (${reasoningTokenCount} tokens)…`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
115
131
|
if (delta?.tool_calls) {
|
|
116
132
|
if (firstToken) {
|
|
117
133
|
firstToken = false
|