@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.
@@ -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
+ }
@@ -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 = 100
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 predict so the checkpoint
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 pingLaya(60_000)
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
+ }
@@ -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