@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.
- package/bin/mipham.ts +11 -1
- package/package.json +1 -1
- package/src/core/credential-masker/search.ts +20 -3
- package/src/core/crsi-modify.ts +10 -1
- package/src/core/crsi-producer.ts +13 -0
- package/src/core/engine.ts +31 -0
- package/src/core/eval-harness.ts +94 -2
- package/src/core/memory/memory-manager.ts +205 -19
- package/src/core/post-flight-checker.ts +102 -0
- package/src/core/project-scaffold.ts +459 -7
- package/src/core/reward-fn.ts +3 -1
- package/src/core/session-log.ts +3 -1
- package/src/core/working-memory.ts +140 -0
- package/src/i18n-core/locales/en-US.json +9 -1
- package/src/i18n-core/locales/zh-CN.json +9 -1
- package/src/index.tsx +1 -1
- package/src/security/fd.ts +52 -0
- package/src/shared/package-info.ts +1 -1
- package/src/tools/agent/memory.ts +5 -1
- package/src/tools/exec/task.ts +21 -2
- package/src/tools/file/edit.ts +46 -15
- package/src/tools/file/grep.ts +16 -2
- package/src/tools/file/read.ts +35 -15
- package/src/tools/file/write.ts +21 -2
- package/src/ui/commands.ts +43 -1
|
@@ -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`
|
|
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.
|
|
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
|
-
|
|
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)
|
package/src/tools/exec/task.ts
CHANGED
|
@@ -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)
|
|
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
|
|
package/src/tools/file/edit.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
}
|
package/src/tools/file/grep.ts
CHANGED
|
@@ -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
|
|
94
|
-
|
|
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) {
|
package/src/tools/file/read.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync,
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
content: '',
|
|
39
|
-
|
|
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)
|
package/src/tools/file/write.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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)
|
package/src/ui/commands.ts
CHANGED
|
@@ -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
|
}
|