@miphamai/cli 0.81.7 → 0.81.8

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.
@@ -1,4 +1,4 @@
1
- import { readFileSync, fstatSync, closeSync, constants } from 'node:fs'
1
+ import { readFileSync, readSync, fstatSync, closeSync, constants } from 'node:fs'
2
2
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
3
3
  import { resolveSafe } from '../../security/path'
4
4
  import { openNoFollow, isSymlinkLoop } from '../../security/fd'
@@ -6,6 +6,115 @@ import type { Service } from '../../vajra'
6
6
  import { toolKey } from '../seam'
7
7
  import { withValidation } from '../validation'
8
8
 
9
+ /** Lines returned when the caller passes no `limit`. */
10
+ const DEFAULT_LIMIT = 2000
11
+
12
+ /** Bytes per syscall while scanning for newlines. */
13
+ const CHUNK_BYTES = 64 * 1024
14
+
15
+ /**
16
+ * How far a single Read may scan to locate the requested lines. Lines are
17
+ * addressed by index, so finding line N means scanning everything before it;
18
+ * this is what keeps that scan from turning a request for two lines of a 2 GB
19
+ * file into a 2 GB read.
20
+ */
21
+ const MAX_SCAN_BYTES = 50_000_000
22
+
23
+ /** Render a line window the way the tool reports it: ` 123\ttext`. */
24
+ function formatLines(lines: string[], offset: number): string {
25
+ return lines.map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`).join('\n')
26
+ }
27
+
28
+ /**
29
+ * Lines `[offset, offset + limit)` of an already-decoded string.
30
+ *
31
+ * Scans newlines instead of `content.split('\n')`, which materializes one JS
32
+ * string for *every* line of the file in order to return `limit` of them. On a
33
+ * 20 MB source, returning 2000 lines that way cost +34 MB of heap for 60-char
34
+ * lines and +100 MB for 3-char lines (333k vs 10M lines — it tracks line count,
35
+ * not bytes); scanning costs ~0 on top of the string it is handed.
36
+ */
37
+ function windowFromString(content: string, offset: number, limit: number): string[] {
38
+ const out: string[] = []
39
+ let lineNo = 0
40
+ let start = 0
41
+ while (out.length < limit) {
42
+ const nl = content.indexOf('\n', start)
43
+ const end = nl === -1 ? content.length : nl
44
+ if (lineNo >= offset) out.push(content.slice(start, end))
45
+ lineNo++
46
+ if (nl === -1) break
47
+ start = nl + 1
48
+ }
49
+ return out
50
+ }
51
+
52
+ /**
53
+ * Lines `[offset, offset + limit)` read from `fd` — only those lines are ever
54
+ * materialized, so cost tracks the window rather than the file: a 2000-line
55
+ * window of a 20 MB file leaves RSS at the process baseline (~38 MB), where
56
+ * reading the file whole and slicing it cost 142–255 MB.
57
+ *
58
+ * Two passes over the same bytes: the first only *looks* for newline bytes to
59
+ * learn where the window starts and ends, the second reads exactly that byte
60
+ * range and decodes it once. Scanning raw bytes is safe because `0x0a` never
61
+ * occurs inside a multi-byte UTF-8 sequence (continuation bytes are >= 0x80),
62
+ * and the decoded range starts and ends on a newline — so neither pass can split
63
+ * a character. Returns null when the window cannot be located within
64
+ * `MAX_SCAN_BYTES`.
65
+ */
66
+ function windowFromFd(fd: number, offset: number, limit: number): string[] | null {
67
+ if (limit <= 0) return []
68
+
69
+ const buf = Buffer.allocUnsafe(CHUNK_BYTES)
70
+ let scanned = 0 // bytes consumed by the scan
71
+ let lineNo = 0
72
+ let windowStart = offset <= 0 ? 0 : -1
73
+ let windowEnd = -1
74
+ let eof = false
75
+
76
+ while (windowEnd === -1 && scanned < MAX_SCAN_BYTES) {
77
+ const n = readSync(fd, buf, 0, buf.length, scanned)
78
+ if (n <= 0) {
79
+ eof = true
80
+ break
81
+ }
82
+ const lastNl = buf.lastIndexOf(0x0a, n - 1)
83
+ if (lastNl !== -1) {
84
+ let from = 0
85
+ for (;;) {
86
+ const nl = buf.indexOf(0x0a, from)
87
+ if (nl === -1 || nl > lastNl) break
88
+ lineNo++ // line `lineNo - 1` ends at this newline
89
+ if (lineNo === offset) windowStart = scanned + nl + 1
90
+ if (lineNo === offset + limit) {
91
+ windowEnd = scanned + nl
92
+ break
93
+ }
94
+ from = nl + 1
95
+ }
96
+ }
97
+ scanned += n
98
+ }
99
+
100
+ if (windowEnd === -1) {
101
+ // Either the file ended (the window is everything that is left) or the scan
102
+ // budget ran out before the window's last line was reached.
103
+ if (!eof) return null
104
+ windowEnd = scanned
105
+ }
106
+ if (windowStart === -1) return [] // `offset` is past the end of the file
107
+
108
+ const out = Buffer.allocUnsafe(windowEnd - windowStart)
109
+ let got = 0
110
+ while (got < out.length) {
111
+ const n = readSync(fd, out, got, out.length - got, windowStart + got)
112
+ if (n <= 0) break
113
+ got += n
114
+ }
115
+ return out.toString('utf-8', 0, got).split('\n')
116
+ }
117
+
9
118
  export function createReadTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
10
119
  return {
11
120
  name: 'Read',
@@ -40,59 +149,56 @@ export function createReadTool(credentialConfig?: CredentialMaskingConfig): Tool
40
149
  throw err
41
150
  }
42
151
 
43
- let content: string
44
152
  try {
45
- const stat = fstatSync(fd)
46
- if (stat.isDirectory()) {
153
+ if (fstatSync(fd).isDirectory()) {
47
154
  return { success: false, content: '', error: `Path is a directory: ${filePath}` }
48
155
  }
49
- // Prevent OOM on single-line files (e.g. 500MB JSON blob)
50
- const MAX_FILE_SIZE = 50_000_000 // 50 MB
51
- if (stat.size > MAX_FILE_SIZE) {
52
- return {
53
- success: false,
54
- content: '',
55
- error: `File too large (${(stat.size / 1e6).toFixed(1)} MB). Max: 50 MB. Use offset/limit for large files.`,
156
+
157
+ const offset = (params.offset as number) || 0
158
+ const limit = (params.limit as number) || DEFAULT_LIMIT
159
+
160
+ // ── Credential masking ──
161
+ // The rule matches on path alone, so this is decided before any read: a
162
+ // masked file needs its whole content (the mask is content-shaped), while
163
+ // every other file is served straight from the fd. Reading the file
164
+ // *first* is what used to make `offset`/`limit` useless on large files —
165
+ // the size check ran before the window was ever applied.
166
+ let result: string | null = null
167
+ if (credentialConfig) {
168
+ const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
169
+ await import('../../core/credential-masker')
170
+ const rule = matchCredentialFile(filePath, credentialConfig)
171
+ if (rule) {
172
+ const masked = maskContent(readFileSync(fd, 'utf-8'), rule)
173
+ // Full-file mask: return the sentinel immediately (offset/limit don't apply)
174
+ result =
175
+ masked === CREDENTIAL_SENTINEL
176
+ ? masked
177
+ : formatLines(windowFromString(masked, offset, limit), offset)
56
178
  }
57
179
  }
58
- content = readFileSync(fd, 'utf-8')
59
- } finally {
60
- closeSync(fd)
61
- }
62
180
 
63
- const offset = (params.offset as number) || 0
64
- const limit = (params.limit as number) || 2000
65
-
66
- // ── Read tracking: mark file as read for Write tool safety ──
67
- ctx.readFiles?.add(filePath)
68
-
69
- // ── Credential masking ──
70
- if (credentialConfig) {
71
- const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
72
- await import('../../core/credential-masker')
73
- const rule = matchCredentialFile(filePath, credentialConfig)
74
- if (rule) {
75
- const masked = maskContent(content, rule)
76
- // Full-file mask: return sentinel immediately (offset/limit don't apply)
77
- if (masked === CREDENTIAL_SENTINEL) {
78
- return { success: true, content: masked }
181
+ if (result === null) {
182
+ const lines = windowFromFd(fd, offset, limit)
183
+ if (lines === null) {
184
+ return {
185
+ success: false,
186
+ content: '',
187
+ error:
188
+ `File too large: scanned ${MAX_SCAN_BYTES / 1e6} MB without reaching the end of lines ` +
189
+ `${offset}–${offset + limit - 1}. Narrow the range (smaller offset or limit), or use a ` +
190
+ `shell tool to slice the file.`,
191
+ }
79
192
  }
80
- // Extract mode: apply offset/limit on masked content
81
- const maskedLines = masked.split('\n')
82
- const maskedSlice = maskedLines.slice(offset, offset + limit)
83
- const maskedResult = maskedSlice
84
- .map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
85
- .join('\n')
86
- return { success: true, content: maskedResult }
193
+ result = formatLines(lines, offset)
87
194
  }
88
- }
89
195
 
90
- const lines = content.split('\n')
91
- const slice = lines.slice(offset, offset + limit)
92
- const result = slice
93
- .map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
94
- .join('\n')
95
- return { success: true, content: result }
196
+ // ── Read tracking: mark file as read for Write tool safety ──
197
+ ctx.readFiles?.add(filePath)
198
+ return { success: true, content: result }
199
+ } finally {
200
+ closeSync(fd)
201
+ }
96
202
  },
97
203
  }
98
204
  }
@@ -26,14 +26,31 @@ export interface CronJob {
26
26
  createdAt: string
27
27
  nextFire: string
28
28
  lastFired: string | null
29
+ /**
30
+ * The directory this job belongs to. The store is global (`~/.mipham/cron/`)
31
+ * and every session's poller reads all of it, so without this a job created in
32
+ * project A gets executed by whichever session in project B happens to be
33
+ * running — its prompt expands against the wrong codebase.
34
+ *
35
+ * Optional because files written before this field existed genuinely lack it;
36
+ * the poller treats a missing `cwd` as "any directory" so those keep firing.
37
+ */
38
+ cwd?: string
39
+ /** Session that created the job. Informational — the poller keys on `cwd`. */
40
+ sessionId?: string
29
41
  }
30
42
 
31
43
  function jobPath(id: string): string {
32
44
  return join(CRON_DIR, `${id}.json`)
33
45
  }
34
46
 
35
- function generateId(cron: string, prompt: string): string {
36
- return createHash('sha256').update(`${cron}:${prompt}`).digest('hex').slice(0, 12)
47
+ /**
48
+ * Job id includes `cwd`: keyed on `cron:prompt` alone, creating the same schedule
49
+ * in two directories wrote to the same file and the second silently replaced the
50
+ * first.
51
+ */
52
+ function generateId(cron: string, prompt: string, cwd: string): string {
53
+ return createHash('sha256').update(`${cwd}:${cron}:${prompt}`).digest('hex').slice(0, 12)
37
54
  }
38
55
 
39
56
  /**
@@ -101,11 +118,11 @@ export const cronCreateTool: ToolDefinition = {
101
118
  },
102
119
  required: ['cron', 'prompt'],
103
120
  },
104
- async execute(params, _ctx) {
121
+ async execute(params, ctx) {
105
122
  const cron = params.cron as string
106
123
  const prompt = params.prompt as string
107
124
  const recurring = params.recurring !== false
108
- const id = generateId(cron, prompt)
125
+ const id = generateId(cron, prompt, ctx.cwd)
109
126
 
110
127
  const now = new Date()
111
128
  const job: CronJob = {
@@ -116,6 +133,8 @@ export const cronCreateTool: ToolDefinition = {
116
133
  createdAt: now.toISOString(),
117
134
  nextFire: computeNextFire(cron, now),
118
135
  lastFired: null,
136
+ cwd: ctx.cwd,
137
+ sessionId: ctx.sessionId,
119
138
  }
120
139
 
121
140
  writeJob(job)
@@ -127,6 +146,7 @@ export const cronCreateTool: ToolDefinition = {
127
146
  `Created ${type} cron job.\n` +
128
147
  `ID: ${id}\n` +
129
148
  `Schedule: ${cron}\n` +
149
+ `Scoped to: ${ctx.cwd}\n` +
130
150
  `Prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? '...' : ''}"`,
131
151
  }
132
152
  },
@@ -182,11 +202,20 @@ export const cronListTool: ToolDefinition = {
182
202
  const lines = [`── Scheduled Cron Jobs (${jobs.length}) ──`, '']
183
203
  for (const j of jobs) {
184
204
  const type = j.recurring ? 'recurring' : 'one-shot'
185
- lines.push(`${j.id} ${j.cron} ${type}`)
205
+ // 目录要看得见:任务被限定在建立它的那个目录,从别的项目 `/schedule` 列出来时
206
+ // 用户得能看出它为什么不在自己这里跑。**没有 cwd 的旧文件恰恰相反** —— 它在任何
207
+ // 目录都会执行,而这里是唯一能看见那件事的地方,所以必须显式标出来(留空等于
208
+ // 把「无归属」显示成「和别的任务一样」)。
209
+ lines.push(`${j.id} ${j.cron} ${type} ${j.cwd ? `[${j.cwd}]` : '[无归属]'}`)
186
210
  lines.push(` ${j.prompt.slice(0, 100)}`)
187
211
  lines.push('')
188
212
  }
189
213
 
214
+ if (jobs.some((j) => !j.cwd)) {
215
+ lines.push('⚠️ [无归属] 是加 cwd 字段之前写的任务:没有目录可判,任何项目里都会执行。')
216
+ lines.push(' 消除办法:在目标目录里用 CronCreate 重建,或让 Mipham 用 CronDelete 删掉。')
217
+ }
218
+
190
219
  return { success: true, content: lines.join('\n') }
191
220
  },
192
221
  }
@@ -1,7 +1,8 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
1
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import { parse as parseYaml, stringify } from 'yaml'
5
+ import { atomicWriteFileSync } from '../../shared/atomic-write'
5
6
  import type { ToolDefinition } from '../../shared/index.ts'
6
7
 
7
8
  const MIPHAM_HOME = join(homedir(), '.mipham')
@@ -60,7 +61,10 @@ export const configTool: ToolDefinition = {
60
61
  obj = obj[k] as Record<string, unknown>
61
62
  }
62
63
  obj[keys[keys.length - 1]!] = params.value
63
- writeFileSync(USER_CONFIG, stringify(config), 'utf-8')
64
+ // 原子写 + 0o600:这是整份 read-modify-write,裸 writeFileSync 原地截断 ——
65
+ // 崩在写中途就留下半截 YAML,而权限由 umask 决定(典型 0644),比同一份配置的
66
+ // 另一个写者 saveProviderApiKey(loader.ts,0600 原子写)更松。
67
+ atomicWriteFileSync(USER_CONFIG, stringify(config), { mode: 0o600 })
64
68
  return { success: true, content: `Set ${key} = ${params.value}` }
65
69
  }
66
70
 
@@ -5191,10 +5191,23 @@ const artifactOpenCmd: CommandHandler = async (ctx, args) => {
5191
5191
  return { content: 'Usage: /artifact open <name>\nExample: /artifact open dashboard' }
5192
5192
  }
5193
5193
 
5194
- const sessionId = 'session-1' // default session
5195
- const port = 9876
5196
- const ext = name.endsWith('.svg') ? '' : '.html'
5197
- const url = `http://localhost:${port}/${sessionId}/${name}${ext}`
5194
+ const { readManifest } = await import('../artifacts/manifest')
5195
+ const { artifactsRoot } = await import('../artifacts/paths')
5196
+ const manifest = readManifest(artifactsRoot(process.cwd()))
5197
+
5198
+ // 先找本会话,再退到任意会话:文件在磁盘上跨会话留存,而 `open` 是用户手敲的。
5199
+ // 原先写死 `session-1` + 端口 9876,工具回报的却是真会话 id 和**实际**端口
5200
+ // (服务端遇到端口占用会自增),所以这条命令从构造上就打不开任何东西。
5201
+ const entry =
5202
+ manifest.artifacts.find((a) => a.name === name && a.sessionId === ctx.sessionId) ??
5203
+ manifest.artifacts.find((a) => a.name === name)
5204
+
5205
+ if (!entry) {
5206
+ return { content: `✗ No artifact named "${name}". Run /artifact list to see them.` }
5207
+ }
5208
+
5209
+ // 用工具落盘时记下的 URL —— 那就是它印给用户的同一个坐标,两边不该各算一次。
5210
+ const url = entry.url
5198
5211
 
5199
5212
  try {
5200
5213
  await openBrowser(url)
@@ -5204,13 +5217,14 @@ const artifactOpenCmd: CommandHandler = async (ctx, args) => {
5204
5217
  }
5205
5218
  }
5206
5219
 
5207
- const artifactListCmd: CommandHandler = async (_ctx, _args) => {
5208
- const { getSessionArtifacts } = await import('../artifacts/manifest')
5209
- const { join } = await import('node:path')
5210
- const { ARTIFACTS_DIR } = await import('../shared/constants')
5220
+ const artifactListCmd: CommandHandler = async (ctx, _args) => {
5221
+ const { readManifest, getSessionArtifacts } = await import('../artifacts/manifest')
5222
+ const { artifactsRoot } = await import('../artifacts/paths')
5211
5223
 
5212
- const dir = join(process.cwd(), ARTIFACTS_DIR)
5213
- const entries = getSessionArtifacts(dir, 'session-1')
5224
+ const dir = artifactsRoot(process.cwd())
5225
+ // 会话 id 取自真实上下文,不是写死的 'session-1' —— 工具落盘时写的是真 id,
5226
+ // 写死的那一支必然过滤出空列表。
5227
+ const entries = getSessionArtifacts(dir, ctx.sessionId)
5214
5228
 
5215
5229
  if (entries.length === 0) {
5216
5230
  return {
@@ -5226,7 +5240,9 @@ const artifactListCmd: CommandHandler = async (_ctx, _args) => {
5226
5240
  )
5227
5241
  }
5228
5242
  lines.push('', ` ${entries.length} artifact(s) — /artifact open <name> to view`)
5229
- lines.push(` Gallery: http://localhost:9876`)
5243
+ // 画廊端口同样不写死:用最近一次落盘记下的那个(服务端会因端口占用自增)。
5244
+ const { port } = readManifest(dir)
5245
+ if (port) lines.push(` Gallery: http://localhost:${port}`)
5230
5246
 
5231
5247
  return { content: lines.join('\n') }
5232
5248
  }
@@ -1,127 +0,0 @@
1
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { homedir } from 'node:os'
4
-
5
- const DEFAULT_VERSIONS_DIR = join(homedir(), '.mipham', 'artifacts')
6
-
7
- export interface ArtifactVersion {
8
- name: string
9
- version: number
10
- path: string
11
- createdAt: string
12
- size: number
13
- }
14
-
15
- /**
16
- * Manages artifact version snapshots on disk.
17
- *
18
- * Directory layout:
19
- * <dir>/<name>/versions/v1.html
20
- * <dir>/<name>/versions/v2.html
21
- * <dir>/<name>/current.html (latest snapshot)
22
- * <dir>/<name>/manifest.json ({ name, currentVersion, versionCount })
23
- */
24
- export class ArtifactVersioning {
25
- private dir: string
26
-
27
- constructor(dir?: string) {
28
- this.dir = dir ?? DEFAULT_VERSIONS_DIR
29
- mkdirSync(this.dir, { recursive: true })
30
- }
31
-
32
- /** Save a new version of an artifact. Returns the assigned version number. */
33
- saveVersion(name: string, content: string): number {
34
- const artifactDir = join(this.dir, name)
35
- const versionsDir = join(artifactDir, 'versions')
36
- mkdirSync(versionsDir, { recursive: true })
37
-
38
- // Determine next version number
39
- const existing = this.listVersions(name)
40
- const nextVersion = (existing.length > 0 ? Math.max(...existing.map((v) => v.version)) : 0) + 1
41
-
42
- // Save versioned file
43
- const versionPath = join(versionsDir, `v${nextVersion}.html`)
44
- writeFileSync(versionPath, content, 'utf-8')
45
-
46
- // Update current.html
47
- writeFileSync(join(artifactDir, 'current.html'), content, 'utf-8')
48
-
49
- // Update manifest
50
- writeFileSync(
51
- join(artifactDir, 'manifest.json'),
52
- JSON.stringify(
53
- {
54
- name,
55
- currentVersion: nextVersion,
56
- versionCount: nextVersion,
57
- },
58
- null,
59
- 2,
60
- ),
61
- )
62
-
63
- return nextVersion
64
- }
65
-
66
- /** List all saved versions for an artifact, newest first. */
67
- listVersions(name: string): ArtifactVersion[] {
68
- const versionsDir = join(this.dir, name, 'versions')
69
- if (!existsSync(versionsDir)) return []
70
-
71
- try {
72
- return readdirSync(versionsDir)
73
- .filter((f) => f.startsWith('v') && f.endsWith('.html'))
74
- .map((f) => {
75
- const vNum = parseInt(f.replace('v', '').replace('.html', ''), 10)
76
- const path = join(versionsDir, f)
77
- const stat = statSync(path)
78
- return {
79
- name,
80
- version: vNum,
81
- path,
82
- createdAt: stat.birthtime.toISOString(),
83
- size: stat.size,
84
- }
85
- })
86
- .sort((a, b) => b.version - a.version)
87
- } catch {
88
- return []
89
- }
90
- }
91
-
92
- /**
93
- * Get artifact content.
94
- * - If a version number is given, returns that specific version.
95
- * - Otherwise returns the latest (current.html).
96
- * Returns null if the requested version does not exist.
97
- */
98
- getVersion(name: string, version?: number): string | null {
99
- const artifactDir = join(this.dir, name)
100
-
101
- if (version !== undefined) {
102
- const vPath = join(artifactDir, 'versions', `v${version}.html`)
103
- return existsSync(vPath) ? readFileSync(vPath, 'utf-8') : null
104
- }
105
-
106
- const currentPath = join(artifactDir, 'current.html')
107
- return existsSync(currentPath) ? readFileSync(currentPath, 'utf-8') : null
108
- }
109
-
110
- /** Produce a simple line-by-line text diff between two versions. */
111
- diff(name: string, v1: number, v2: number): string {
112
- const content1 = this.getVersion(name, v1) || ''
113
- const content2 = this.getVersion(name, v2) || ''
114
- const lines1 = content1.split('\n')
115
- const lines2 = content2.split('\n')
116
-
117
- const diffLines: string[] = []
118
- const maxLen = Math.max(lines1.length, lines2.length)
119
- for (let i = 0; i < maxLen; i++) {
120
- if (lines1[i] !== lines2[i]) {
121
- if (lines1[i] !== undefined) diffLines.push(`- ${lines1[i]}`)
122
- if (lines2[i] !== undefined) diffLines.push(`+ ${lines2[i]}`)
123
- }
124
- }
125
- return diffLines.length > 0 ? diffLines.join('\n') : '(no changes)'
126
- }
127
- }