@miphamai/cli 0.66.0 → 0.69.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,140 @@
1
+ /**
2
+ * Working Memory — 用「已验证的当前任务状态」接地记忆召回(借鉴 Recuris 的 W,仅借概念)。
3
+ *
4
+ * 问题(治「前存后忘」):MemoryManager.recall 用 TF-IDF 去和「刚说的 query」比,
5
+ * 历史越长,旧但相关的记忆越容易被埋掉。Recuris 的解法是把召回绑定到「当前还剩
6
+ * 什么没做」(pending/in_progress),而不是只绑定到「刚说了什么」。
7
+ *
8
+ * 本文件分两层:
9
+ * - Phase 1(纯只读):`renderWorkingState(tasks)` 把「现有任务追踪」的未完成任务
10
+ * 渲染成紧凑接地串,拼进 recall 的 query(状态接地召回)。
11
+ * - Phase 2(证据接地状态机):`WorkingMemory` 类复现 Recuris「w_t → ρ → E_t → (a,o)
12
+ * → C → w_{t+1}」环——`done` 只能由 PostFlightChecker 的 `supported` 决策推进,
13
+ * `blocked` 由 `rejected` 置位;**没有「模型自称 done」的入口**(不信任模型叙事,
14
+ * 只信任工具/env 观察)。
15
+ */
16
+
17
+ import type { CheckerDecision } from './post-flight-checker'
18
+
19
+ export interface WorkingGoal {
20
+ subject: string
21
+ status: string
22
+ }
23
+
24
+ /** 渲染「当前仍未完成」的任务主题为紧凑接地串;无未完成任务返回空串。 */
25
+ export function renderWorkingState(tasks: ReadonlyArray<WorkingGoal>): string {
26
+ const open = tasks.filter((t) => t.status === 'pending' || t.status === 'in_progress')
27
+ if (open.length === 0) return ''
28
+ return open
29
+ .map((t) => t.subject.trim())
30
+ .filter(Boolean)
31
+ .join('\n')
32
+ }
33
+
34
+ // ── Phase 2:证据接地状态机 ──
35
+
36
+ export type GoalStatus = 'pending' | 'done' | 'blocked'
37
+
38
+ export interface GoalState {
39
+ id: string
40
+ content: string
41
+ status: GoalStatus
42
+ /** 推进/置位时固化的证据(checkerId + reason),供审计「谁证明了这个状态」。 */
43
+ evidence: string[]
44
+ }
45
+
46
+ /** 从现有任务追踪(Task 工具的 Task 列表)同步的最小结构,避免 import Task 造成循环依赖。 */
47
+ export interface TaskLike {
48
+ id: string
49
+ subject: string
50
+ status: string
51
+ }
52
+
53
+ export class WorkingMemory {
54
+ private goals = new Map<string, GoalState>()
55
+
56
+ /** 注册一个待办目标(新目标默认 pending)。重复 id 只更新 content,不回退已验证的状态。 */
57
+ setGoal(id: string, content: string): void {
58
+ const existing = this.goals.get(id)
59
+ if (existing) {
60
+ existing.content = content
61
+ } else {
62
+ this.goals.set(id, { id, content, status: 'pending', evidence: [] })
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 证据接地推进:`done` 只能由 checker `supported` 决策推进;`rejected` 置 `blocked`;
68
+ * `no-checker` 不改状态(模型自称不算)。已 `done` 的目标不再回退。
69
+ */
70
+ observe(goalId: string, decision: CheckerDecision): void {
71
+ const g = this.goals.get(goalId)
72
+ if (!g || g.status === 'done') return
73
+ if (decision.verdict === 'supported') {
74
+ g.status = 'done'
75
+ g.evidence.push(`supported:${decision.checkerId}`)
76
+ } else if (decision.verdict === 'rejected') {
77
+ g.status = 'blocked'
78
+ g.evidence.push(`rejected:${decision.checkerId}:${decision.reason}`)
79
+ }
80
+ // no-checker:证据不足,状态不变
81
+ }
82
+
83
+ getGoal(id: string): GoalState | undefined {
84
+ return this.goals.get(id)
85
+ }
86
+
87
+ /** 从现有任务追踪(Task 工具的 Task 列表)重建目标状态——TaskList 是唯一真源。
88
+ * pending/in_progress → pending;completed → done;failed → blocked;deleted 跳过。 */
89
+ syncFromTasks(tasks: ReadonlyArray<TaskLike>): void {
90
+ this.goals.clear()
91
+ for (const t of tasks) {
92
+ if (t.status === 'deleted') continue
93
+ const status: GoalStatus =
94
+ t.status === 'completed' ? 'done' : t.status === 'failed' ? 'blocked' : 'pending'
95
+ this.goals.set(t.id, { id: t.id, content: t.subject, status, evidence: [] })
96
+ }
97
+ }
98
+
99
+ /** 紧凑三态块(检索/系统提示用);无任何目标时返回空串。 */
100
+ renderWorkingState(): string {
101
+ if (this.goals.size === 0) return ''
102
+ const byStatus: Record<GoalStatus, string[]> = { pending: [], done: [], blocked: [] }
103
+ for (const g of this.goals.values()) {
104
+ byStatus[g.status].push(g.content.trim())
105
+ }
106
+ return (['pending', 'done', 'blocked'] as const)
107
+ .map((s) => {
108
+ const items = byStatus[s].filter(Boolean)
109
+ return `[WORKING] ${s}: ${items.length > 0 ? items.join(', ') : '(none)'}`
110
+ })
111
+ .join('\n')
112
+ }
113
+ }
114
+
115
+ // ── 证据账本(engine post-flight 后写入;Task 完成门读取) ──
116
+
117
+ export interface ToolEvidence {
118
+ toolName: string
119
+ decision: CheckerDecision
120
+ at: number
121
+ }
122
+
123
+ /** 会话级证据账本(对齐 Task 工具的 module-level `tasks` Map,session 生命周期)。 */
124
+ const evidenceLog: ToolEvidence[] = []
125
+
126
+ /** 记录一次工具事后决策。no-checker 不产证据(忽略)。engine post-flight 后调用。 */
127
+ export function recordToolEvidence(toolName: string, decision: CheckerDecision): void {
128
+ if (decision.verdict === 'no-checker') return
129
+ evidenceLog.push({ toolName, decision, at: Date.now() })
130
+ }
131
+
132
+ /** 某时间点之后是否有 supported 决策(用于任务完成证据门)。 */
133
+ export function hasSupportedEvidenceSince(sinceAt: number): boolean {
134
+ return evidenceLog.some((e) => e.decision.verdict === 'supported' && e.at >= sinceAt)
135
+ }
136
+
137
+ /** 测试用:清空证据账本(避免跨测试污染)。 */
138
+ export function clearEvidenceLog(): void {
139
+ evidenceLog.length = 0
140
+ }
@@ -407,7 +407,15 @@
407
407
  "error": "─ Memory ─\n\nCould not read memory directory.",
408
408
  "location": "Location: {path}",
409
409
  "total": "Total: {count} memor{plural}",
410
- "footer": "Memories are used by the AI to provide personalized context across sessions.\nEach file in ~/.mipham/memory/ represents one remembered fact or preference."
410
+ "footer": "Memories are used by the AI to provide personalized context across sessions.\nEach file in ~/.mipham/memory/ represents one remembered fact or preference.",
411
+ "gc_title": "─ Memory GC ─",
412
+ "gc_none": "Nothing to archive. All auto-* memories are recent or have been recalled.",
413
+ "gc_archived": "Archived ({count}):",
414
+ "gc_candidates": "Manual review candidates ({count}):",
415
+ "gc_done": "Done. Archived files moved to ~/.mipham/memory/archive/",
416
+ "consolidate_title": "─ Memory Consolidate ─",
417
+ "consolidate_none": "No auto-* memories to consolidate.",
418
+ "consolidate_result": "Consolidated: {removed} auto-* removed, {merged} lessons written."
411
419
  },
412
420
  "upgrade": {
413
421
  "uptodate": "── Upgrade Mipham Code ──\n\nCurrent version: v{current}\nLatest: v{latest}\n\n✓ Already up to date.\n\nTo check manually: https://www.npmjs.com/package/@miphamai/cli",
@@ -407,7 +407,15 @@
407
407
  "error": "─ 记忆 ─\n\n无法读取记忆目录。",
408
408
  "location": "位置: {path}",
409
409
  "total": "总计: {count} 条记忆",
410
- "footer": "AI 使用记忆提供跨会话的个性化上下文。\n~/.mipham/memory/ 中每个文件代表一条已记住的事实或偏好。"
410
+ "footer": "AI 使用记忆提供跨会话的个性化上下文。\n~/.mipham/memory/ 中每个文件代表一条已记住的事实或偏好。",
411
+ "gc_title": "─ 记忆 GC ─",
412
+ "gc_none": "无需归档。所有 auto-* 记忆要么较新、要么已被召回。",
413
+ "gc_archived": "已归档({count} 条):",
414
+ "gc_candidates": "待人工确认({count} 条):",
415
+ "gc_done": "完成。归档文件已移到 ~/.mipham/memory/archive/",
416
+ "consolidate_title": "─ 记忆合并 ─",
417
+ "consolidate_none": "没有需要合并的 auto-* 记忆。",
418
+ "consolidate_result": "合并完成:删除 {removed} 条 auto-*,写入 {merged} 条 lesson-*。"
411
419
  },
412
420
  "upgrade": {
413
421
  "uptodate": "── 升级 Mipham Code ──\n\n当前版本: v{current}\n最新版本: v{latest}\n\n✓ 已是最新版本。\n\n手动检查: https://www.npmjs.com/package/@miphamai/cli",
package/src/index.tsx CHANGED
@@ -314,7 +314,7 @@ export async function runApp(options: RunOptions): Promise<void> {
314
314
  const { isEmptyProject } = await import('./core/project-scaffold')
315
315
  if (isEmptyProject(process.cwd())) {
316
316
  console.log(
317
- '\n💡 空目录。运行 `mipham init` 生成规范项目文档(CLAUDE.md / MIPHAM.md / README.md),或直接开始对话。\n',
317
+ '\n💡 空目录。运行 `mipham init` 生成基础文档,或 `mipham init --full` 生成完整规范(八章 CLAUDE.md + 7 个 .md + .github 模板),或直接开始对话。\n',
318
318
  )
319
319
  }
320
320
 
@@ -0,0 +1,52 @@
1
+ import { openSync, writeFileSync, closeSync, constants } from 'node:fs'
2
+
3
+ /**
4
+ * O_NOFOLLOW is POSIX-only; on Windows it is undefined. Fall back to 0 (a
5
+ * no-op) — Windows symlinks are governed by different semantics, and
6
+ * `resolveSafe` still rejects UNC/device-namespace paths and workspace
7
+ * escapes before any file is opened.
8
+ */
9
+ const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0
10
+
11
+ /**
12
+ * Open a file with O_NOFOLLOW so a symlink swapped in after path resolution
13
+ * (a TOCTOU race) fails with ELOOP instead of being silently followed.
14
+ *
15
+ * `resolveSafe` resolves symlinks at check time, but the returned canonical
16
+ * path is a *string*; re-opening it by path re-resolves the filesystem. If a
17
+ * concurrent process replaces the final component with a symlink between the
18
+ * check and the open, O_NOFOLLOW makes the open fail closed.
19
+ *
20
+ * Callers combine with their own flags (O_RDONLY / O_WRONLY / O_CREAT /
21
+ * O_TRUNC). Throws ELOOP when the path is a symlink.
22
+ */
23
+ export function openNoFollow(path: string, flags: number, mode?: number): number {
24
+ return openSync(path, flags | O_NOFOLLOW, mode)
25
+ }
26
+
27
+ /** True if `err` is ELOOP — a symlink was encountered where O_NOFOLLOW forbade following it. */
28
+ export function isSymlinkLoop(err: unknown): boolean {
29
+ return (err as NodeJS.ErrnoException)?.code === 'ELOOP'
30
+ }
31
+
32
+ /**
33
+ * Open a file O_WRONLY|O_NOFOLLOW, write `content`, and close. Used by Write
34
+ * and Edit so a symlink swapped in after `resolveSafe` is rejected rather than
35
+ * followed (writing through it would clobber an out-of-workspace target).
36
+ *
37
+ * `flags` should include O_WRONLY|O_TRUNC (and O_CREAT for Write). Throws
38
+ * ELOOP when the path is a symlink.
39
+ */
40
+ export function writeFileNoFollow(
41
+ path: string,
42
+ content: string,
43
+ flags: number,
44
+ mode = 0o666,
45
+ ): void {
46
+ const fd = openSync(path, flags | O_NOFOLLOW, mode)
47
+ try {
48
+ writeFileSync(fd, content, 'utf-8')
49
+ } finally {
50
+ closeSync(fd)
51
+ }
52
+ }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.66.0' as const
12
+ export const PACKAGE_VERSION = '0.69.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -3,6 +3,8 @@ import { join } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import type { ToolDefinition } from '../../shared/index.ts'
5
5
  import { MemoryManager } from '../../core/memory/memory-manager'
6
+ import { renderWorkingState } from '../../core/working-memory'
7
+ import { getTasks } from '../exec/task'
6
8
 
7
9
  const MEMORY_DIR = join(homedir(), '.mipham', 'memory')
8
10
 
@@ -58,7 +60,9 @@ export const memoryTool: ToolDefinition = {
58
60
  if (!query) return { success: false, content: '', error: 'query is required' }
59
61
  const mm = new MemoryManager(MEMORY_DIR)
60
62
  mm.loadAll()
61
- const results = mm.recall(query, 10)
63
+ // 状态接地(治「前存后忘」):除了精确匹配 query,还带上「当前未完成任务」相关的记忆。
64
+ const grounding = renderWorkingState(getTasks())
65
+ const results = mm.recall(query, 10, grounding)
62
66
  if (results.length === 0) return { success: true, content: '(no matching memories)' }
63
67
  const lines = results.map((entry) => {
64
68
  const snippet = entry.content.replace(/\n+/g, ' ').trim().slice(0, 140)
@@ -1,4 +1,5 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
+ import { hasSupportedEvidenceSince } from '../../core/working-memory'
2
3
 
3
4
  export interface Task {
4
5
  id: string
@@ -11,6 +12,8 @@ export interface Task {
11
12
  blockedBy: string[]
12
13
  owner?: string
13
14
  createdAt: string
15
+ /** 完成证据:completed 时是否有 supported 决策接地(软门,unverified 只标记不阻断)。 */
16
+ completionEvidence?: 'supported' | 'unverified'
14
17
  /** Background task output content (set when a background agent completes). */
15
18
  output?: string
16
19
  /** Background task error message (set when a background agent fails). */
@@ -218,6 +221,9 @@ export const taskTool: ToolDefinition = {
218
221
  `Status: ${task.status}`,
219
222
  `Description: ${task.description || '(none)'}`,
220
223
  ]
224
+ if (task.completionEvidence) {
225
+ lines.push(`Completion evidence: ${task.completionEvidence}`)
226
+ }
221
227
  if (task.activeForm) lines.push(`Active form: ${task.activeForm}`)
222
228
  if (task.owner) lines.push(`Owner: ${task.owner}`)
223
229
  if (task.blocks.length) lines.push(`Blocks: ${task.blocks.join(', ')}`)
@@ -255,7 +261,16 @@ export const taskTool: ToolDefinition = {
255
261
  if (params.subject !== undefined) task.subject = params.subject as string
256
262
  if (params.description !== undefined) task.description = params.description as string
257
263
  if (params.activeForm !== undefined) task.activeForm = params.activeForm as string
258
- if (params.status !== undefined) task.status = params.status as Task['status']
264
+ if (params.status !== undefined) {
265
+ task.status = params.status as Task['status']
266
+ // 完成证据门(软门):completed 时按「自任务创建以来是否有 supported 决策」标记。
267
+ // unverified 只标记不阻断(对齐 CRSI「最小干预」)。
268
+ if (params.status === 'completed') {
269
+ task.completionEvidence = hasSupportedEvidenceSince(new Date(task.createdAt).getTime())
270
+ ? 'supported'
271
+ : 'unverified'
272
+ }
273
+ }
259
274
  if (params.owner !== undefined) task.owner = params.owner as string
260
275
 
261
276
  // Merge dependency arrays
@@ -281,10 +296,14 @@ export const taskTool: ToolDefinition = {
281
296
  const blockedNote = isBlocked(task)
282
297
  ? ` (blocked — waiting on: ${getBlockingIds(task).join(', ')})`
283
298
  : ''
299
+ const unverifiedNote =
300
+ task.status === 'completed' && task.completionEvidence === 'unverified'
301
+ ? '\n⚠ completed without verified evidence (no supported tool decision since task start)'
302
+ : ''
284
303
 
285
304
  return {
286
305
  success: true,
287
- content: `Task #${taskId} updated.\n${formatTask(task)}${blockedNote}`,
306
+ content: `Task #${taskId} updated.\n${formatTask(task)}${blockedNote}${unverifiedNote}`,
288
307
  }
289
308
  }
290
309
 
@@ -1,6 +1,7 @@
1
- import { readFileSync, writeFileSync } from 'node:fs'
1
+ import { readFileSync, closeSync, constants } from 'node:fs'
2
2
  import type { ToolDefinition } from '../../shared/index.ts'
3
3
  import { resolveSafe } from '../../security/path'
4
+ import { openNoFollow, writeFileNoFollow, isSymlinkLoop } from '../../security/fd'
4
5
 
5
6
  /**
6
7
  * Characters considered part of an identifier in source code.
@@ -89,7 +90,22 @@ export const editTool: ToolDefinition = {
89
90
  }
90
91
  }
91
92
 
92
- const content = readFileSync(filePath, 'utf-8')
93
+ // O_NOFOLLOW: fail closed on a symlink swapped in after resolveSafe (TOCTOU).
94
+ let rfd: number
95
+ try {
96
+ rfd = openNoFollow(filePath, constants.O_RDONLY)
97
+ } catch (err) {
98
+ if (isSymlinkLoop(err)) {
99
+ return { success: false, content: '', error: `Path is a symbolic link: ${filePath}` }
100
+ }
101
+ throw err
102
+ }
103
+ let content: string
104
+ try {
105
+ content = readFileSync(rfd, 'utf-8')
106
+ } finally {
107
+ closeSync(rfd)
108
+ }
93
109
 
94
110
  // ── Read tracking: mark as read before editing ──
95
111
  ctx.readFiles?.add(filePath)
@@ -114,28 +130,43 @@ export const editTool: ToolDefinition = {
114
130
  }
115
131
  }
116
132
 
133
+ let result: string
134
+ let count: number
117
135
  if (replaceAll) {
118
136
  // Build result by splicing at each match (in reverse so indices stay valid)
119
- let result = content
137
+ result = content
120
138
  for (let i = matches.length - 1; i >= 0; i--) {
121
139
  const idx = matches[i]!
122
140
  result = result.slice(0, idx) + newStr + result.slice(idx + oldStr.length)
123
141
  }
124
- writeFileSync(filePath, result, 'utf-8')
125
- return { success: true, content: `Replaced ${matches.length} occurrences in ${filePath}` }
142
+ count = matches.length
143
+ } else {
144
+ if (matches.length > 1) {
145
+ return {
146
+ success: false,
147
+ content: '',
148
+ error: 'old_string is not unique in file. Use replace_all or make it more specific.',
149
+ }
150
+ }
151
+
152
+ const idx = matches[0]!
153
+ result = content.slice(0, idx) + newStr + content.slice(idx + oldStr.length)
154
+ count = 1
126
155
  }
127
156
 
128
- if (matches.length > 1) {
129
- return {
130
- success: false,
131
- content: '',
132
- error: 'old_string is not unique in file. Use replace_all or make it more specific.',
157
+ // O_NOFOLLOW: reject a symlink swapped in after the read (TOCTOU) instead of
158
+ // writing through it to an out-of-workspace target.
159
+ try {
160
+ writeFileNoFollow(filePath, result, constants.O_WRONLY | constants.O_TRUNC)
161
+ } catch (err) {
162
+ if (isSymlinkLoop(err)) {
163
+ return { success: false, content: '', error: `Path is a symbolic link: ${filePath}` }
133
164
  }
165
+ throw err
166
+ }
167
+ return {
168
+ success: true,
169
+ content: `Replaced ${count} occurrence${count === 1 ? '' : 's'} in ${filePath}`,
134
170
  }
135
-
136
- const idx = matches[0]!
137
- const updated = content.slice(0, idx) + newStr + content.slice(idx + oldStr.length)
138
- writeFileSync(filePath, updated, 'utf-8')
139
- return { success: true, content: `Replaced 1 occurrence in ${filePath}` }
140
171
  },
141
172
  }
@@ -90,8 +90,22 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
90
90
  // rg not installed → fall through to grep
91
91
  }
92
92
 
93
- // 2. fallback to plain grep
94
- const grepArgs = ['grep', '-rn', pattern, searchPath]
93
+ // 2. fallback: `find -type f -exec grep -Hn {} +`
94
+ // `-type f` skips symlinks (file and dir) so the fallback can't follow
95
+ // one out of the workspace — macOS BSD `grep -r` follows symlinks and
96
+ // would leak target contents, unlike ripgrep's default no-follow.
97
+ const grepArgs = [
98
+ 'find',
99
+ searchPath,
100
+ '-type',
101
+ 'f',
102
+ '-exec',
103
+ 'grep',
104
+ '-Hn',
105
+ pattern,
106
+ '{}',
107
+ '+',
108
+ ]
95
109
  try {
96
110
  const { stdout, timedOut, exitCode } = await runSearch(grepArgs, ctx.cwd, GREP_TIMEOUT_MS)
97
111
  if (timedOut) {
@@ -1,6 +1,7 @@
1
- import { readFileSync, existsSync, statSync } from 'node:fs'
1
+ import { readFileSync, fstatSync, closeSync, constants } from 'node:fs'
2
2
  import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
3
3
  import { resolveSafe } from '../../security/path'
4
+ import { openNoFollow, isSymlinkLoop } from '../../security/fd'
4
5
  import type { Service } from '../../vajra'
5
6
  import { toolKey } from '../seam'
6
7
  import { withValidation } from '../validation'
@@ -23,25 +24,44 @@ export function createReadTool(credentialConfig?: CredentialMaskingConfig): Tool
23
24
  },
24
25
  async execute(params, ctx) {
25
26
  const filePath = resolveSafe(ctx.cwd, params.file_path as string)
26
- if (!existsSync(filePath)) {
27
- return { success: false, content: '', error: `File not found: ${filePath}` }
28
- }
29
- const stat = statSync(filePath)
30
- if (stat.isDirectory()) {
31
- return { success: false, content: '', error: `Path is a directory: ${filePath}` }
27
+
28
+ // O_NOFOLLOW: fail closed if the path was swapped to a symlink after
29
+ // resolveSafe (TOCTOU) — never follow it to read outside the workspace.
30
+ let fd: number
31
+ try {
32
+ fd = openNoFollow(filePath, constants.O_RDONLY)
33
+ } catch (err) {
34
+ if (isSymlinkLoop(err)) {
35
+ return { success: false, content: '', error: `Path is a symbolic link: ${filePath}` }
36
+ }
37
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
38
+ return { success: false, content: '', error: `File not found: ${filePath}` }
39
+ }
40
+ throw err
32
41
  }
33
- // Prevent OOM on single-line files (e.g. 500MB JSON blob)
34
- const MAX_FILE_SIZE = 50_000_000 // 50 MB
35
- if (stat.size > MAX_FILE_SIZE) {
36
- return {
37
- success: false,
38
- content: '',
39
- error: `File too large (${(stat.size / 1e6).toFixed(1)} MB). Max: 50 MB. Use offset/limit for large files.`,
42
+
43
+ let content: string
44
+ try {
45
+ const stat = fstatSync(fd)
46
+ if (stat.isDirectory()) {
47
+ return { success: false, content: '', error: `Path is a directory: ${filePath}` }
48
+ }
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.`,
56
+ }
40
57
  }
58
+ content = readFileSync(fd, 'utf-8')
59
+ } finally {
60
+ closeSync(fd)
41
61
  }
62
+
42
63
  const offset = (params.offset as number) || 0
43
64
  const limit = (params.limit as number) || 2000
44
- const content = readFileSync(filePath, 'utf-8')
45
65
 
46
66
  // ── Read tracking: mark file as read for Write tool safety ──
47
67
  ctx.readFiles?.add(filePath)
@@ -1,7 +1,8 @@
1
- import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
1
+ import { mkdirSync, existsSync, constants } from 'node:fs'
2
2
  import { dirname } from 'node:path'
3
3
  import type { ToolDefinition } from '../../shared/index.ts'
4
4
  import { resolveSafe } from '../../security/path'
5
+ import { writeFileNoFollow, isSymlinkLoop } from '../../security/fd'
5
6
 
6
7
  export const writeTool: ToolDefinition = {
7
8
  name: 'Write',
@@ -42,7 +43,25 @@ export const writeTool: ToolDefinition = {
42
43
  }
43
44
 
44
45
  mkdirSync(dirname(filePath), { recursive: true })
45
- writeFileSync(filePath, content, 'utf-8')
46
+
47
+ // O_NOFOLLOW: reject a symlink swapped in after resolveSafe (TOCTOU) —
48
+ // writing through it would clobber an out-of-workspace target.
49
+ try {
50
+ writeFileNoFollow(
51
+ filePath,
52
+ content,
53
+ constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC,
54
+ )
55
+ } catch (err) {
56
+ if (isSymlinkLoop(err)) {
57
+ return {
58
+ success: false,
59
+ content: '',
60
+ error: `Refusing to write through a symbolic link: ${filePath}`,
61
+ }
62
+ }
63
+ throw err
64
+ }
46
65
 
47
66
  // Track as read since we just wrote it (future writes allowed)
48
67
  ctx.readFiles?.add(filePath)
@@ -46,6 +46,7 @@ import {
46
46
  import { NPM_UPDATE_COMMAND, PACKAGE_VERSION, COAUTHOR_TRAILER } from '../shared/index.ts'
47
47
  import { getPreference } from '../config/preferences'
48
48
  import { loadCrossSessionConfig } from '../config/loader'
49
+ import { getMemoryManager } from '../core/memory/memory-loader'
49
50
  import { stripIndent } from './strip-indent.js'
50
51
  import { createT } from '../i18n-core/t'
51
52
  import type { TranslationMap } from '../i18n-core/types'
@@ -3766,7 +3767,7 @@ const saveCmd: CommandHandler = (ctx, args) => {
3766
3767
  // Memory Management
3767
3768
  // ═══════════════════════════════════════════════════════════════
3768
3769
 
3769
- const memoryCmd: CommandHandler = async (ctx) => {
3770
+ const memoryCmd: CommandHandler = async (ctx, args) => {
3770
3771
  const t = resolveT(ctx)
3771
3772
  const { existsSync, readdirSync, readFileSync, statSync } = await import('node:fs')
3772
3773
  const { join } = await import('node:path')
@@ -3774,6 +3775,47 @@ const memoryCmd: CommandHandler = async (ctx) => {
3774
3775
  const home = process.env.HOME || '~'
3775
3776
  const memoryDir = join(home, '.mipham', 'memory')
3776
3777
 
3778
+ // /memory gc — 记忆卫生:归档「0 召回 + 过期」的 auto-* 记忆(手写只报告)
3779
+ if (args[0]?.toLowerCase() === 'gc') {
3780
+ const mm = getMemoryManager()
3781
+ const { archived, candidates } = mm.gc()
3782
+ const lines: string[] = [t('commands.memories.gc_title'), '']
3783
+ if (archived.length === 0 && candidates.length === 0) {
3784
+ lines.push(t('commands.memories.gc_none'))
3785
+ } else {
3786
+ if (archived.length > 0) {
3787
+ lines.push(t('commands.memories.gc_archived', { count: String(archived.length) }))
3788
+ lines.push(...archived.map((n) => ` - ${n}`))
3789
+ lines.push('')
3790
+ }
3791
+ if (candidates.length > 0) {
3792
+ lines.push(t('commands.memories.gc_candidates', { count: String(candidates.length) }))
3793
+ lines.push(...candidates.map((n) => ` - ${n}`))
3794
+ lines.push('')
3795
+ }
3796
+ lines.push(t('commands.memories.gc_done'))
3797
+ }
3798
+ return { content: lines.join('\n') }
3799
+ }
3800
+
3801
+ // /memory consolidate — 会话记忆合并:把 auto-* 聚簇成持久化 lesson-*
3802
+ if (args[0]?.toLowerCase() === 'consolidate') {
3803
+ const mm = getMemoryManager()
3804
+ const { merged, removed } = mm.consolidateAutoMemories()
3805
+ const lines: string[] = [t('commands.memories.consolidate_title'), '']
3806
+ if (removed === 0) {
3807
+ lines.push(t('commands.memories.consolidate_none'))
3808
+ } else {
3809
+ lines.push(
3810
+ t('commands.memories.consolidate_result', {
3811
+ removed: String(removed),
3812
+ merged: String(merged),
3813
+ }),
3814
+ )
3815
+ }
3816
+ return { content: lines.join('\n') }
3817
+ }
3818
+
3777
3819
  if (!existsSync(memoryDir)) {
3778
3820
  return { content: t('commands.memories.no_memories') }
3779
3821
  }