@miphamai/cli 0.85.1 → 0.85.3
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 +42 -2
- package/package.json +1 -1
- package/skills/standard/mipham-code-setup.SKILL.md +9 -2
- package/src/agent/agent-experience.ts +3 -2
- package/src/agent/background-registry.ts +7 -3
- package/src/agent/cross-session/discovery.ts +5 -12
- package/src/agent/cross-session/file-inbox.ts +2 -2
- package/src/agent/effectiveness-tracker.ts +3 -2
- package/src/agent/sub-agent.ts +67 -6
- package/src/agent/types.ts +10 -0
- package/src/agent-view/agent-view-manager.ts +46 -0
- package/src/agent-view/dashboard.tsx +106 -16
- package/src/agent-view/session-view.tsx +128 -0
- package/src/commands/autoloop-journal.ts +6 -5
- package/src/commands/environment.ts +11 -8
- package/src/commands/project.ts +5 -3
- package/src/config/credential-crypto.ts +13 -1
- package/src/config/keys-manager.ts +7 -4
- package/src/config/loader.ts +144 -41
- package/src/config/preferences.ts +6 -3
- package/src/core/constitution-loader.ts +3 -2
- package/src/core/context.ts +35 -6
- package/src/core/crsi-producer.ts +4 -2
- package/src/core/crsi-sandbox.ts +2 -1
- package/src/core/dream-engine.ts +5 -11
- package/src/core/engine.ts +9 -4
- package/src/core/error-signature-db.ts +3 -2
- package/src/core/eval-harness.ts +3 -3
- package/src/core/hooks-executor.ts +60 -2
- package/src/core/instructions.ts +69 -48
- package/src/core/memory/memory-manager.ts +10 -6
- package/src/core/permission-audit.ts +13 -6
- package/src/core/permission-config.ts +28 -7
- package/src/core/permission-rules.ts +99 -5
- package/src/core/permission.ts +6 -1
- package/src/core/rule-engine.ts +3 -2
- package/src/core/session-log.ts +48 -11
- package/src/core/session-store.ts +64 -44
- package/src/daemon/attach-protocol.ts +30 -3
- package/src/daemon/auth.ts +4 -3
- package/src/daemon/index.ts +4 -3
- package/src/daemon/remote-engine.ts +173 -24
- package/src/daemon/server.ts +51 -1
- package/src/daemon/session-worker.ts +34 -1
- package/src/i18n-core/locales/en-US.json +1 -0
- package/src/i18n-core/locales/zh-CN.json +1 -0
- package/src/index.tsx +75 -17
- package/src/mcp/oauth.ts +47 -5
- package/src/mcp/token-store.ts +10 -11
- package/src/providers/openai-compat.ts +11 -0
- package/src/shared/arg-validation.ts +74 -2
- package/src/shared/package-info.ts +1 -1
- package/src/shared/regular-file.ts +63 -0
- package/src/shared/sanitize.ts +14 -2
- package/src/skills/bundled-skills.ts +1 -1
- package/src/tools/agent/memory.ts +4 -2
- package/src/tools/agent/workflow.ts +6 -3
- package/src/tools/exec/task.ts +82 -30
- package/src/tools/scheduling/cron.ts +3 -9
- package/src/ui/app.tsx +78 -14
- package/src/ui/commands.ts +73 -31
- package/src/ui/ctrl-c-confirm.ts +63 -0
- package/src/workflow/journal.ts +80 -26
package/src/config/loader.ts
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
readFileSync,
|
|
3
|
-
existsSync,
|
|
4
|
-
copyFileSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
readdirSync,
|
|
7
|
-
unlinkSync,
|
|
8
|
-
chmodSync,
|
|
9
|
-
} from 'node:fs'
|
|
1
|
+
import { existsSync, copyFileSync, mkdirSync, readdirSync, unlinkSync, chmodSync } from 'node:fs'
|
|
10
2
|
import { join, dirname } from 'node:path'
|
|
11
3
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
12
4
|
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
5
|
+
import { isRegularFile, readRegularFileSync } from '../shared/regular-file'
|
|
13
6
|
import type {
|
|
14
7
|
MiphamConfig,
|
|
15
8
|
ProviderConfig,
|
|
@@ -41,7 +34,16 @@ const BACKUP_PREFIX = 'config.backup-'
|
|
|
41
34
|
function safeParseYaml(path: string, label: string): Partial<MiphamConfig> | null {
|
|
42
35
|
try {
|
|
43
36
|
if (!existsSync(path)) return null
|
|
44
|
-
|
|
37
|
+
// `existsSync` 先过一遍只为「缺席不吭声」;真正决定读不读的是**文件类型**
|
|
38
|
+
// —— FIFO 上 `readFileSync` 会阻塞到有写者出现,那次挂死没有报错也没有
|
|
39
|
+
// 计时器救得回来(见 shared/regular-file.ts)。
|
|
40
|
+
const raw = readRegularFileSync(path)
|
|
41
|
+
if (raw === null) {
|
|
42
|
+
process.stderr.write(
|
|
43
|
+
`⚠ Mipham Code: ${label} (${path}) is not a readable regular file — ignoring it.\n`,
|
|
44
|
+
)
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
45
47
|
return parseYaml(raw) as Partial<MiphamConfig>
|
|
46
48
|
} catch (err: unknown) {
|
|
47
49
|
const msg = err instanceof Error ? err.message : String(err)
|
|
@@ -145,13 +147,43 @@ function mergeConfig(
|
|
|
145
147
|
return merged
|
|
146
148
|
}
|
|
147
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Drop `permission` from a **project-level** `config.yml` before it is merged,
|
|
152
|
+
* reporting it instead of applying it.
|
|
153
|
+
*
|
|
154
|
+
* `permission` is a ceiling, not a rule: it decides what the approval gate lets
|
|
155
|
+
* through without asking. Every other project-level key is a preference that a
|
|
156
|
+
* repository may reasonably commit; this one means "and do not ask me about the
|
|
157
|
+
* commands in here" — which is a decision about the operator, made by whoever
|
|
158
|
+
* wrote the repository. The same reasoning admits project-level `permissions.deny`
|
|
159
|
+
* (it narrows) and withholds `permissions.defaultMode` in `settings.json` (it
|
|
160
|
+
* widens) — the two files are the same door, so both are closed here.
|
|
161
|
+
*
|
|
162
|
+
* `mergeConfig` has no per-key allowlist, so this has to happen at the two points
|
|
163
|
+
* the project file enters. Reported rather than dropped silently: a repo whose
|
|
164
|
+
* setting stopped working should say so out loud, and silence is exactly what
|
|
165
|
+
* "your config was ignored" looks like from the outside.
|
|
166
|
+
*/
|
|
167
|
+
function stripProjectPermission(cfg: Partial<MiphamConfig>, path: string): Partial<MiphamConfig> {
|
|
168
|
+
if (cfg.permission === undefined) return cfg
|
|
169
|
+
const { permission, ...rest } = cfg
|
|
170
|
+
const shown = typeof permission === 'string' ? permission : JSON.stringify(permission)
|
|
171
|
+
process.stderr.write(
|
|
172
|
+
`⚠ Mipham Code: ignored permission: ${shown} from project config ${path}\n` +
|
|
173
|
+
` (a repository must not choose the approval gate — set it in ~/.mipham/config.yml,\n` +
|
|
174
|
+
` or pass --permission <mode> for this invocation)\n`,
|
|
175
|
+
)
|
|
176
|
+
return rest as Partial<MiphamConfig>
|
|
177
|
+
}
|
|
178
|
+
|
|
148
179
|
/**
|
|
149
180
|
* Save a timestamped backup of config.yml to ~/.mipham/.
|
|
150
181
|
* Keeps at most 5 backups; older ones are pruned.
|
|
151
182
|
*/
|
|
152
183
|
function backupConfig(configPath: string): void {
|
|
153
184
|
try {
|
|
154
|
-
|
|
185
|
+
// 备份源同样要过类型闸:`copyFileSync` 打开 FIFO 读端一样会等到有写者为止。
|
|
186
|
+
if (!isRegularFile(configPath)) return
|
|
155
187
|
mkdirSync(MIPHAM_HOME, { recursive: true, mode: 0o700 })
|
|
156
188
|
|
|
157
189
|
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
|
@@ -190,6 +222,10 @@ export function tryRestoreFromBackup(configPath: string): boolean {
|
|
|
190
222
|
|
|
191
223
|
if (files.length === 0) return false
|
|
192
224
|
|
|
225
|
+
// 目标已经在那儿而且不是普通文件(FIFO/socket/目录)时不还原:往 FIFO 写
|
|
226
|
+
// 会挡在「等读者」上,把「恢复」变成又一次挂死;目录则会 EISDIR。缺席才写。
|
|
227
|
+
if (existsSync(configPath) && !isRegularFile(configPath)) return false
|
|
228
|
+
|
|
193
229
|
const latestBackup = join(MIPHAM_HOME, files[0]!)
|
|
194
230
|
copyFileSync(latestBackup, configPath)
|
|
195
231
|
process.stderr.write(`⚠ Mipham Code: restored config from backup (${files[0]})\n`)
|
|
@@ -220,8 +256,8 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
|
|
|
220
256
|
|
|
221
257
|
for (const path of searchPaths) {
|
|
222
258
|
try {
|
|
223
|
-
|
|
224
|
-
|
|
259
|
+
const raw = readRegularFileSync(path)
|
|
260
|
+
if (raw === null) continue
|
|
225
261
|
// Every McpServerConfig field is optional here (the name comes from the
|
|
226
262
|
// key), so a field added to the type is accepted without touching this.
|
|
227
263
|
const parsed = JSON.parse(raw) as {
|
|
@@ -247,11 +283,21 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
|
|
|
247
283
|
|
|
248
284
|
/**
|
|
249
285
|
* Parsed `settings.json` (Claude Code convention): hooks + permissions.
|
|
250
|
-
* Hooks are additive across levels; permissions allow/deny are deduped unions
|
|
286
|
+
* Hooks are additive across levels; permissions allow/deny are deduped unions —
|
|
287
|
+
* and `defaultMode` is the one member that is **not** merged, because it is a
|
|
288
|
+
* ceiling rather than a rule (see `loadSettingsJson`).
|
|
251
289
|
*/
|
|
252
290
|
export interface SettingsJson {
|
|
253
291
|
hooks: SettingsHooks
|
|
254
|
-
|
|
292
|
+
/**
|
|
293
|
+
* `defaultMode` is present only when the **user-level** file named one, and it
|
|
294
|
+
* is passed through **raw** (not validated here): the accepted spelling is
|
|
295
|
+
* `ALL_MODES`, and the single place that enforces it — plus the warning for a
|
|
296
|
+
* value that is not a mode — is `PermissionSystem.setDefaultLevel`. A second
|
|
297
|
+
* validator here would be a second value domain, and the two would drift the
|
|
298
|
+
* day a mode is added.
|
|
299
|
+
*/
|
|
300
|
+
permissions: { allow: string[]; deny: string[]; defaultMode?: string }
|
|
255
301
|
/**
|
|
256
302
|
* Present (and `true`) only when the project-level file really did declare
|
|
257
303
|
* hooks and they were withheld because the caller did not vouch for the
|
|
@@ -259,6 +305,13 @@ export interface SettingsJson {
|
|
|
259
305
|
* so a caller announcing the skip cannot announce one that never happened.
|
|
260
306
|
*/
|
|
261
307
|
projectHooksSkipped?: true
|
|
308
|
+
/**
|
|
309
|
+
* Same shape, for `permissions.defaultMode`: present only when the
|
|
310
|
+
* project-level file really declared a mode and it was **withheld**. Unlike
|
|
311
|
+
* `projectHooksSkipped` this is not conditioned on trust — the ceiling does not
|
|
312
|
+
* move for a file that arrives with the code, trusted workspace or not.
|
|
313
|
+
*/
|
|
314
|
+
projectModeSkipped?: true
|
|
262
315
|
/**
|
|
263
316
|
* The subset of `hooks` that came from the project-level file — the entries
|
|
264
317
|
* the workspace-trust gate governs. Absent unless the caller vouched for the
|
|
@@ -282,14 +335,35 @@ export interface SettingsJson {
|
|
|
282
335
|
* established trust (or that only *display* the configured list) opt in
|
|
283
336
|
* explicitly. The flag gates hooks only: `permissions` still merge from both
|
|
284
337
|
* levels, since that question is answered by the mode ceiling, not by trust.
|
|
338
|
+
*
|
|
339
|
+
* **That last sentence is the reason `defaultMode` is the exception.** The
|
|
340
|
+
* ceiling only answers the trust question while repository-controlled files
|
|
341
|
+
* cannot move it; a repo that ships `.mipham/settings.json` with
|
|
342
|
+
* `permissions.defaultMode: "auto"` would otherwise hand itself the approval
|
|
343
|
+
* gate. So `defaultMode` is read from the **user-level file only**, and a
|
|
344
|
+
* project-level one is withheld and *reported* (`projectModeSkipped`) rather
|
|
345
|
+
* than dropped in silence — same treatment as a `plan`/`acceptEdits` value that
|
|
346
|
+
* used to be dropped entirely, except that the drop there fell back to the
|
|
347
|
+
* *wider* `default`. The upstream convention says the same thing in its own
|
|
348
|
+
* words: repo-level settings cannot grant `defaultMode`; adopt it in user
|
|
349
|
+
* settings instead.
|
|
350
|
+
*
|
|
351
|
+
* One consequence to keep in mind when reading a merged result: `permissions`
|
|
352
|
+
* is still provenance-free for allow/deny, so a caller cannot tell which of
|
|
353
|
+
* those two rules came from the repository. That is deliberate (above), and it
|
|
354
|
+
* is why the ceiling has to be the thing that repels the repo-controlled half.
|
|
285
355
|
*/
|
|
286
356
|
export function loadSettingsJson(
|
|
287
357
|
cwd: string = process.cwd(),
|
|
288
358
|
options: { includeProjectHooks?: boolean } = {},
|
|
289
359
|
): SettingsJson {
|
|
290
360
|
const hooks: SettingsHooks = {}
|
|
291
|
-
const permissions
|
|
361
|
+
const permissions: { allow: string[]; deny: string[]; defaultMode?: string } = {
|
|
362
|
+
allow: [],
|
|
363
|
+
deny: [],
|
|
364
|
+
}
|
|
292
365
|
let projectHooksSkipped = false
|
|
366
|
+
let projectModeSkipped = false
|
|
293
367
|
// The project file's entries, kept out of the merge so provenance survives it.
|
|
294
368
|
const projectHooks: SettingsHooks = {}
|
|
295
369
|
|
|
@@ -304,11 +378,11 @@ export function loadSettingsJson(
|
|
|
304
378
|
|
|
305
379
|
for (const { path, readHooks, isProject } of searchPaths) {
|
|
306
380
|
try {
|
|
307
|
-
|
|
308
|
-
|
|
381
|
+
const raw = readRegularFileSync(path)
|
|
382
|
+
if (raw === null) continue
|
|
309
383
|
const parsed = JSON.parse(raw) as {
|
|
310
384
|
hooks?: Record<string, unknown>
|
|
311
|
-
permissions?: { allow?: unknown; deny?: unknown }
|
|
385
|
+
permissions?: { allow?: unknown; deny?: unknown; defaultMode?: unknown }
|
|
312
386
|
}
|
|
313
387
|
|
|
314
388
|
if (!readHooks) {
|
|
@@ -336,6 +410,20 @@ export function loadSettingsJson(
|
|
|
336
410
|
}
|
|
337
411
|
|
|
338
412
|
if (parsed.permissions) {
|
|
413
|
+
// A mode counts as *declared* only when it is a non-empty string — the
|
|
414
|
+
// same test the hooks branch above uses, so a marker cannot outrun the
|
|
415
|
+
// fact it reports. What the string says is not checked here (see the
|
|
416
|
+
// `permissions` field doc): a typo must reach the applier, which is the
|
|
417
|
+
// one place that knows the accepted spellings and issues the warning.
|
|
418
|
+
const declaredMode =
|
|
419
|
+
typeof parsed.permissions.defaultMode === 'string' &&
|
|
420
|
+
parsed.permissions.defaultMode.trim() !== ''
|
|
421
|
+
? parsed.permissions.defaultMode
|
|
422
|
+
: undefined
|
|
423
|
+
if (declaredMode !== undefined) {
|
|
424
|
+
if (isProject) projectModeSkipped = true
|
|
425
|
+
else permissions.defaultMode = declaredMode
|
|
426
|
+
}
|
|
339
427
|
for (const key of ['allow', 'deny'] as const) {
|
|
340
428
|
const list = parsed.permissions[key]
|
|
341
429
|
if (!Array.isArray(list)) continue
|
|
@@ -355,6 +443,7 @@ export function loadSettingsJson(
|
|
|
355
443
|
// gated on the strength of a file with nothing in it.
|
|
356
444
|
const result: SettingsJson = { hooks, permissions }
|
|
357
445
|
if (projectHooksSkipped) result.projectHooksSkipped = true
|
|
446
|
+
if (projectModeSkipped) result.projectModeSkipped = true
|
|
358
447
|
if (Object.values(projectHooks).some((entries) => Array.isArray(entries) && entries.length > 0)) {
|
|
359
448
|
result.projectHooks = projectHooks
|
|
360
449
|
}
|
|
@@ -376,10 +465,11 @@ export function settingsPathFor(scope: SettingsScope, cwd: string = process.cwd(
|
|
|
376
465
|
* not something to clobber — the user's other settings live in the same file.
|
|
377
466
|
*/
|
|
378
467
|
export function readSettingsDoc(path: string): Record<string, unknown> {
|
|
379
|
-
|
|
468
|
+
const raw = readRegularFileSync(path)
|
|
469
|
+
if (raw === null) return {}
|
|
380
470
|
let parsed: unknown
|
|
381
471
|
try {
|
|
382
|
-
parsed = JSON.parse(
|
|
472
|
+
parsed = JSON.parse(raw)
|
|
383
473
|
} catch {
|
|
384
474
|
throw new Error(`${path} is not valid JSON. Fix or remove it, then retry.`)
|
|
385
475
|
}
|
|
@@ -448,12 +538,24 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
|
|
|
448
538
|
|
|
449
539
|
let config = { ...DEFAULT_CONFIG }
|
|
450
540
|
|
|
541
|
+
// The project file enters `loadConfig` at **two** points (fresh parse, and the
|
|
542
|
+
// parse that follows a restore from backup). Both go through here, so the guard
|
|
543
|
+
// on `permission` cannot be present on one path and missing on the other — a
|
|
544
|
+
// corrupted config that recovers from a backup is still the same repository's
|
|
545
|
+
// file, and "corrupt it once, get the setting honored" would be a bypass of the
|
|
546
|
+
// one key that is refused.
|
|
547
|
+
const applyProjectConfig = (parsed: Partial<MiphamConfig>): void => {
|
|
548
|
+
config = mergeConfig(config, stripProjectPermission(parsed, configPath), false)
|
|
549
|
+
}
|
|
550
|
+
|
|
451
551
|
// ── Load project-level config ──
|
|
452
552
|
const projectConfig = safeParseYaml(configPath, 'project config')
|
|
453
553
|
if (projectConfig) {
|
|
454
|
-
|
|
455
|
-
} else if (
|
|
456
|
-
//
|
|
554
|
+
applyProjectConfig(projectConfig)
|
|
555
|
+
} else if (isRegularFile(configPath)) {
|
|
556
|
+
// A real file is there but failed to parse — try to restore from backup.
|
|
557
|
+
// 非普通文件走不到这里:那不是「损坏的配置」,而是根本不该当配置读的东西
|
|
558
|
+
// (FIFO/socket/目录),「恢复」对它会变成往 FIFO 里写、又一次挂死。
|
|
457
559
|
process.stderr.write(`⚠ Mipham Code: project config is corrupted, attempting recovery...\n`)
|
|
458
560
|
if (!tryRestoreFromBackup(configPath)) {
|
|
459
561
|
process.stderr.write(`⚠ Mipham Code: no backup available for project config. Skipping.\n`)
|
|
@@ -461,7 +563,7 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
|
|
|
461
563
|
// Retry parsing after restore
|
|
462
564
|
const restored = safeParseYaml(configPath, 'restored project config')
|
|
463
565
|
if (restored) {
|
|
464
|
-
|
|
566
|
+
applyProjectConfig(restored)
|
|
465
567
|
}
|
|
466
568
|
}
|
|
467
569
|
}
|
|
@@ -470,8 +572,9 @@ export function loadConfig(cwd: string = process.cwd()): MiphamConfig {
|
|
|
470
572
|
const userConfig = safeParseYaml(userConfigPath, 'user config')
|
|
471
573
|
if (userConfig) {
|
|
472
574
|
config = mergeConfig(config, userConfig, true)
|
|
473
|
-
} else if (
|
|
474
|
-
//
|
|
575
|
+
} else if (isRegularFile(userConfigPath)) {
|
|
576
|
+
// 用户的 config.yml 是真文件但解析不了 —— 才谈得上「恢复」。判据同上:
|
|
577
|
+
// 非普通文件不是损坏的配置。
|
|
475
578
|
process.stderr.write(`⚠ Mipham Code: user config is corrupted, attempting recovery...\n`)
|
|
476
579
|
if (!tryRestoreFromBackup(userConfigPath)) {
|
|
477
580
|
process.stderr.write(`⚠ Mipham Code: no backup available for user config. Skipping.\n`)
|
|
@@ -545,8 +648,8 @@ export function loadInferenceHookConfig(): InferenceHookConfig {
|
|
|
545
648
|
const paths = [userConfigPath]
|
|
546
649
|
for (const path of paths) {
|
|
547
650
|
try {
|
|
548
|
-
|
|
549
|
-
|
|
651
|
+
const raw = readRegularFileSync(path)
|
|
652
|
+
if (raw === null) continue
|
|
550
653
|
const parsed = parseYaml(raw) as Record<string, unknown>
|
|
551
654
|
const section = parsed.inference_hooks as Partial<InferenceHookConfig> | undefined
|
|
552
655
|
if (section) {
|
|
@@ -587,8 +690,8 @@ function mergeCredentialMaskingFile(
|
|
|
587
690
|
allowLoosening: boolean,
|
|
588
691
|
): CredentialMaskingConfig {
|
|
589
692
|
try {
|
|
590
|
-
|
|
591
|
-
|
|
693
|
+
const raw = readRegularFileSync(path)
|
|
694
|
+
if (raw === null) return merged
|
|
592
695
|
const parsed = parseYaml(raw) as Record<string, unknown>
|
|
593
696
|
const section = parsed.credential_masking as Partial<CredentialMaskingConfig> | undefined
|
|
594
697
|
if (!section) return merged
|
|
@@ -664,8 +767,8 @@ export function loadBackgroundAgentConfig(cwd: string = process.cwd()): Backgrou
|
|
|
664
767
|
const paths = [userConfigPath, configPath]
|
|
665
768
|
for (const path of paths) {
|
|
666
769
|
try {
|
|
667
|
-
|
|
668
|
-
|
|
770
|
+
const raw = readRegularFileSync(path)
|
|
771
|
+
if (raw === null) continue
|
|
669
772
|
const parsed = parseYaml(raw) as Record<string, unknown>
|
|
670
773
|
const section = parsed.background_agent as Partial<BackgroundAgentConfig> | undefined
|
|
671
774
|
if (section) {
|
|
@@ -697,8 +800,8 @@ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessio
|
|
|
697
800
|
const paths = [userConfigPath, configPath] // project wins (loaded last)
|
|
698
801
|
for (const path of paths) {
|
|
699
802
|
try {
|
|
700
|
-
|
|
701
|
-
|
|
803
|
+
const raw = readRegularFileSync(path)
|
|
804
|
+
if (raw === null) continue
|
|
702
805
|
const parsed = parseYaml(raw) as Record<string, unknown>
|
|
703
806
|
const section = parsed.cross_session as Partial<CrossSessionConfig> | undefined
|
|
704
807
|
if (section) {
|
|
@@ -753,11 +856,11 @@ function decryptProviderApiKeys(providers: ProviderConfig[] | undefined): void {
|
|
|
753
856
|
export function getProviderApiKey(providerId: string, cwd: string = process.cwd()): string | null {
|
|
754
857
|
const userConfigPath = join(MIPHAM_HOME, 'config.yml')
|
|
755
858
|
const projectConfigPath = join(cwd, MIPHAM_DIR, 'config.yml')
|
|
756
|
-
const configPath =
|
|
859
|
+
const configPath = isRegularFile(userConfigPath) ? userConfigPath : projectConfigPath
|
|
757
860
|
|
|
758
861
|
try {
|
|
759
|
-
|
|
760
|
-
|
|
862
|
+
const raw = readRegularFileSync(configPath)
|
|
863
|
+
if (raw === null) return null
|
|
761
864
|
const doc = (parseYaml(raw) as Record<string, unknown>) || {}
|
|
762
865
|
const providers = (doc.providers as Array<Record<string, unknown>>) || []
|
|
763
866
|
const p = providers.find((x) => x.id === providerId)
|
|
@@ -788,9 +891,9 @@ export function saveProviderApiKey(providerId: string, apiKey: string): boolean
|
|
|
788
891
|
|
|
789
892
|
// Read existing config (or start fresh)
|
|
790
893
|
let doc: Record<string, unknown> = {}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
doc = (parseYaml(
|
|
894
|
+
const existing = readRegularFileSync(configPath)
|
|
895
|
+
if (existing !== null) {
|
|
896
|
+
doc = (parseYaml(existing) as Record<string, unknown>) || {}
|
|
794
897
|
}
|
|
795
898
|
|
|
796
899
|
// Find and update the provider in the providers array
|
|
@@ -5,16 +5,19 @@
|
|
|
5
5
|
* NOT for config.yml settings — those belong in the YAML config system.
|
|
6
6
|
* NOT for secrets — this file is plain JSON, not encrypted.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { existsSync, mkdirSync } from 'node:fs'
|
|
9
9
|
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
10
|
+
import { readRegularFileSync } from '../shared/regular-file'
|
|
10
11
|
import { miphamHome } from '../core/paths.ts'
|
|
11
12
|
|
|
12
13
|
const PREFS_PATH = miphamHome('preferences.json')
|
|
13
14
|
|
|
14
15
|
function readPrefs(): Record<string, string> {
|
|
15
16
|
try {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
// 类型闸在读取之前 —— FIFO 上的 `readFileSync` 会一直等写者(见
|
|
18
|
+
// shared/regular-file.ts)。
|
|
19
|
+
const raw = readRegularFileSync(PREFS_PATH)
|
|
20
|
+
if (raw === null) return {}
|
|
18
21
|
const parsed: unknown = JSON.parse(raw)
|
|
19
22
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
|
|
20
23
|
return parsed as Record<string, string>
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* Format: YAML with schema validation
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { readFileSync,
|
|
16
|
+
import { readFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
17
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
17
18
|
import { miphamHome } from './paths.ts'
|
|
18
19
|
import alignmentVocabulary from './alignment-vocabulary.json' with { type: 'json' }
|
|
19
20
|
|
|
@@ -103,7 +104,7 @@ export class ConstitutionLoader {
|
|
|
103
104
|
try {
|
|
104
105
|
const dir = miphamHome()
|
|
105
106
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
106
|
-
|
|
107
|
+
atomicWriteFileSync(this.path, this.serializeToYaml(DEFAULT_CONSTITUTION), { mode: 0o644 })
|
|
107
108
|
} catch {
|
|
108
109
|
// Best-effort — default constitution works in-memory
|
|
109
110
|
}
|
package/src/core/context.ts
CHANGED
|
@@ -37,6 +37,14 @@ interface Checkpoint {
|
|
|
37
37
|
export class ContextManager {
|
|
38
38
|
private messages: Message[] = []
|
|
39
39
|
private systemPrompt = ''
|
|
40
|
+
/**
|
|
41
|
+
* 系统提示里的**权限段**是读时派生的,不是组装时烘进 `systemPrompt` 的一份拷贝。
|
|
42
|
+
*
|
|
43
|
+
* 烘进去的那份拷贝会与执行分叉:Shift+Tab 之后闸门与页脚都变了,模型手里还是旧指令
|
|
44
|
+
* —— 往窄切是自纠正的(模型比闸门更保守),**往宽切**则让模型拒绝做它已经被允许做的事。
|
|
45
|
+
* `index.tsx` 把它接到 live `PermissionSystem.getMode()` 上,于是切一次档,下一次请求就变。
|
|
46
|
+
*/
|
|
47
|
+
private permissionContextSource: (() => string) | null = null
|
|
40
48
|
private estimatedTokens = 0
|
|
41
49
|
private checkpoints: Checkpoint[] = []
|
|
42
50
|
private checkpointCounter = 0
|
|
@@ -116,11 +124,32 @@ export class ContextManager {
|
|
|
116
124
|
|
|
117
125
|
setSystemPrompt(prompt: string): void {
|
|
118
126
|
this.systemPrompt = prompt
|
|
119
|
-
this.estimatedTokens = this.estimateTokens(
|
|
127
|
+
this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 接线点(`index.tsx`):把权限段接到 live `PermissionSystem.getMode()` 上。
|
|
132
|
+
*
|
|
133
|
+
* 传 `null` 撤销。**不接**时系统提示里没有权限段(这里是唯一的施加点,故
|
|
134
|
+
* `test/integrity/permission-status-parity.test.ts` 从源码侧断这一行在场)。
|
|
135
|
+
*/
|
|
136
|
+
setPermissionContextSource(fn: (() => string) | null): void {
|
|
137
|
+
this.permissionContextSource = fn
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 存储的提示 + 读时派生的权限段。
|
|
142
|
+
*
|
|
143
|
+
* 段尾追加(而非插回原来的中段位置)是刻意的:只切档时**前缀保持不变**,
|
|
144
|
+
* 提供方的 prefix cache 仍能命中到权限段之前的部分。
|
|
145
|
+
*/
|
|
146
|
+
private composedSystemPrompt(): string {
|
|
147
|
+
const block = this.permissionContextSource?.() ?? ''
|
|
148
|
+
return block ? `${this.systemPrompt}\n\n---\n\n${block}` : this.systemPrompt
|
|
120
149
|
}
|
|
121
150
|
|
|
122
151
|
getSystemPrompt(): string {
|
|
123
|
-
return this.
|
|
152
|
+
return this.composedSystemPrompt()
|
|
124
153
|
}
|
|
125
154
|
|
|
126
155
|
addMessage(msg: Message): void {
|
|
@@ -239,7 +268,7 @@ export class ContextManager {
|
|
|
239
268
|
}
|
|
240
269
|
|
|
241
270
|
// Re-estimate tokens
|
|
242
|
-
this.estimatedTokens = this.estimateTokens(this.
|
|
271
|
+
this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
|
|
243
272
|
for (const msg of this.messages) {
|
|
244
273
|
this.estimatedTokens += this.estimateTokens(
|
|
245
274
|
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
@@ -257,7 +286,7 @@ export class ContextManager {
|
|
|
257
286
|
this.messages = []
|
|
258
287
|
this.checkpoints = []
|
|
259
288
|
this.checkpointCounter = 0
|
|
260
|
-
this.estimatedTokens = this.estimateTokens(this.
|
|
289
|
+
this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
|
|
261
290
|
}
|
|
262
291
|
|
|
263
292
|
getMessageCount(): number {
|
|
@@ -273,7 +302,7 @@ export class ContextManager {
|
|
|
273
302
|
replaceMessages(messages: Message[]): void {
|
|
274
303
|
this.messages = messages
|
|
275
304
|
// Re-estimate tokens
|
|
276
|
-
this.estimatedTokens = this.estimateTokens(this.
|
|
305
|
+
this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
|
|
277
306
|
for (const msg of messages) {
|
|
278
307
|
this.estimatedTokens += this.estimateTokens(
|
|
279
308
|
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
@@ -418,7 +447,7 @@ export class ContextManager {
|
|
|
418
447
|
|
|
419
448
|
/** Re-estimate tokens from system prompt + current messages. */
|
|
420
449
|
private reEstimateTokens(): void {
|
|
421
|
-
this.estimatedTokens = this.estimateTokens(this.
|
|
450
|
+
this.estimatedTokens = this.estimateTokens(this.composedSystemPrompt())
|
|
422
451
|
for (const msg of this.messages) {
|
|
423
452
|
this.estimatedTokens += this.estimateTokens(
|
|
424
453
|
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
import type { CrsiInsight } from './auto-memory'
|
|
14
14
|
import type { MetaRule } from './meta-rule-engine'
|
|
15
15
|
import type { Llm } from '../providers/llm'
|
|
16
|
-
import { readdirSync,
|
|
16
|
+
import { readdirSync, readFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
17
17
|
import { join } from 'node:path'
|
|
18
|
+
import { appendRegularFileSync } from '../shared/regular-file'
|
|
18
19
|
import { miphamHome } from './paths.ts'
|
|
19
20
|
|
|
20
21
|
/** 教训文件(相对仓库根)。预建,沙箱只能改已存在文件。 */
|
|
@@ -538,7 +539,8 @@ export function hasProposedProse(id: string): boolean {
|
|
|
538
539
|
export function appendProseProposal(record: ProseProposalRecord): void {
|
|
539
540
|
try {
|
|
540
541
|
mkdirSync(miphamHome('crsi'), { recursive: true })
|
|
541
|
-
|
|
542
|
+
// FIFO 路径上「写不进去」与下面的 catch 同级:ledger 非关键,但**不挂**。
|
|
543
|
+
appendRegularFileSync(proseLedgerFile(), JSON.stringify(record) + '\n')
|
|
542
544
|
} catch {
|
|
543
545
|
// ledger 非关键,失败不影响提议本身
|
|
544
546
|
}
|
package/src/core/crsi-sandbox.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { execSync } from 'node:child_process'
|
|
18
18
|
import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
|
|
19
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
19
20
|
import { join, resolve, sep, posix } from 'node:path'
|
|
20
21
|
import { tmpdir } from 'node:os'
|
|
21
22
|
import { randomUUID } from 'node:crypto'
|
|
@@ -642,7 +643,7 @@ export class CrsiSandbox {
|
|
|
642
643
|
// Persist to ~/.mipham/crsi-sandbox/
|
|
643
644
|
const reportPath = join(REPORT_DIR, `${this.sessionReport.sessionId}.json`)
|
|
644
645
|
try {
|
|
645
|
-
|
|
646
|
+
atomicWriteFileSync(reportPath, JSON.stringify(this.sessionReport, null, 2), { mode: 0o644 })
|
|
646
647
|
} catch {
|
|
647
648
|
// Best-effort persistence
|
|
648
649
|
}
|
package/src/core/dream-engine.ts
CHANGED
|
@@ -25,15 +25,8 @@
|
|
|
25
25
|
* → Write consolidated memory + generate DreamReport
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
readFileSync,
|
|
31
|
-
writeFileSync,
|
|
32
|
-
unlinkSync,
|
|
33
|
-
existsSync,
|
|
34
|
-
mkdirSync,
|
|
35
|
-
statSync,
|
|
36
|
-
} from 'node:fs'
|
|
28
|
+
import { readdirSync, readFileSync, unlinkSync, existsSync, mkdirSync, statSync } from 'node:fs'
|
|
29
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
37
30
|
import { join } from 'node:path'
|
|
38
31
|
import { miphamHome } from './paths.ts'
|
|
39
32
|
|
|
@@ -320,7 +313,8 @@ export class DreamEngine {
|
|
|
320
313
|
try {
|
|
321
314
|
const links = secondary.map((s) => `- See also: [[${s.name}]]`).join('\n')
|
|
322
315
|
const updated = primary.content + `\n\n## Related\n${links}`
|
|
323
|
-
|
|
316
|
+
// 记忆正文:与 memory-manager 同权限语义(用户可读可编辑的内容)。
|
|
317
|
+
atomicWriteFileSync(primary.path, updated, { mode: 0o644 })
|
|
324
318
|
|
|
325
319
|
for (const s of secondary) {
|
|
326
320
|
try {
|
|
@@ -532,7 +526,7 @@ export class DreamEngine {
|
|
|
532
526
|
log.unshift({ timestamp, actions })
|
|
533
527
|
// Keep last 10 dream cycles
|
|
534
528
|
if (log.length > 10) log = log.slice(0, 10)
|
|
535
|
-
|
|
529
|
+
atomicWriteFileSync(this.dreamLog, JSON.stringify(log, null, 2), { mode: 0o644 })
|
|
536
530
|
} catch {
|
|
537
531
|
// Best-effort persistence
|
|
538
532
|
}
|
package/src/core/engine.ts
CHANGED
|
@@ -488,10 +488,15 @@ export class QueryEngine {
|
|
|
488
488
|
try {
|
|
489
489
|
for await (const chunk of this.llmChat({
|
|
490
490
|
model: this.registry.getActiveModel(),
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
491
|
+
// The instruction goes in `systemPrompt`, **not** as a `system` message
|
|
492
|
+
// in the array. A system *entry* is a header, and the providers do not
|
|
493
|
+
// agree on one that arrives inside `messages`: anthropic drops every
|
|
494
|
+
// system entry (its protocol wants the top-level param, anthropic.ts),
|
|
495
|
+
// so the array form reached the OpenAI-compatible providers and no one
|
|
496
|
+
// else — the summarizer's instruction was missing for anthropic users
|
|
497
|
+
// while it worked here. `systemPrompt` is the one spelling both read.
|
|
498
|
+
systemPrompt: summaryPrompt,
|
|
499
|
+
messages: [{ role: 'user', content: text }],
|
|
495
500
|
maxTokens: 2000,
|
|
496
501
|
})) {
|
|
497
502
|
if (chunk.type === 'text' && chunk.content) {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* PreFlightChecker.check() → ErrorSignatureDB.match()
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { mkdirSync, readFileSync,
|
|
17
|
+
import { mkdirSync, readFileSync, existsSync } from 'node:fs'
|
|
18
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
18
19
|
import { join } from 'node:path'
|
|
19
20
|
import { randomUUID } from 'node:crypto'
|
|
20
21
|
import { miphamHome } from './paths.ts'
|
|
@@ -101,7 +102,7 @@ export class ErrorSignatureDB {
|
|
|
101
102
|
try {
|
|
102
103
|
mkdirSync(join(this.storePath, '..'), { recursive: true })
|
|
103
104
|
const arr = Array.from(this.signatures.values())
|
|
104
|
-
|
|
105
|
+
atomicWriteFileSync(this.storePath, JSON.stringify(arr, null, 2), { mode: 0o644 })
|
|
105
106
|
this.dirty = false
|
|
106
107
|
} catch {
|
|
107
108
|
// Best-effort persistence — never crash on write failure
|
package/src/core/eval-harness.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
import { join } from 'node:path'
|
|
15
15
|
import { tmpdir } from 'node:os'
|
|
16
|
-
import { mkdirSync,
|
|
16
|
+
import { mkdirSync, readFileSync, existsSync } from 'node:fs'
|
|
17
|
+
import { appendRegularFileSync } from '../shared/regular-file'
|
|
17
18
|
import { ExperienceRuleEngine } from './rule-engine'
|
|
18
19
|
import { ConstitutionLoader, DEFAULT_CONSTITUTION } from './constitution-loader'
|
|
19
20
|
import { ErrorSignatureDB } from './error-signature-db'
|
|
@@ -119,7 +120,7 @@ export function appendEvalScore(
|
|
|
119
120
|
): void {
|
|
120
121
|
try {
|
|
121
122
|
mkdirSync(miphamHome('crsi'), { recursive: true })
|
|
122
|
-
|
|
123
|
+
appendRegularFileSync(
|
|
123
124
|
SCORES_FILE,
|
|
124
125
|
JSON.stringify({
|
|
125
126
|
name,
|
|
@@ -131,7 +132,6 @@ export function appendEvalScore(
|
|
|
131
132
|
// 读取侧跳过 —— 这是向后兼容的承重判据,别改成 `results: []`。
|
|
132
133
|
...(report.results ? { results: report.results.map(toContractResultRecord) } : {}),
|
|
133
134
|
}) + '\n',
|
|
134
|
-
'utf-8',
|
|
135
135
|
)
|
|
136
136
|
} catch {
|
|
137
137
|
// rewards 日志是非关键的——失败不影响评估本身
|