@agfpd/iapeer 0.2.11 → 0.2.13
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/package.json +1 -1
- package/src/cli/index.ts +56 -1
- package/src/index.ts +5 -0
- package/src/launch/nativeMemory.test.ts +150 -0
- package/src/launch/nativeMemory.ts +185 -0
- package/src/onboard/memory.test.ts +157 -0
- package/src/onboard/memory.ts +124 -0
- package/src/provision/index.ts +34 -0
- package/src/provision/provision.test.ts +41 -0
- package/src/status/index.ts +119 -0
- package/src/status/status.test.ts +125 -0
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -352,7 +352,8 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
352
352
|
rollback revert to the previous binary (.prev) + restart the daemon
|
|
353
353
|
version | --version | -v print the installed binary's version
|
|
354
354
|
daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
|
|
355
|
-
onboard [--dry-run] [--infra <csv>]
|
|
355
|
+
onboard [--dry-run] [--infra <csv>] [--no-memory] [--memory <pkg>] register the agfpd marketplace (+ infra runtimes; + default memory provider, default YES)
|
|
356
|
+
status host snapshot: version, daemon health, memory slot (<provider> | none)
|
|
356
357
|
install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
|
|
357
358
|
init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
|
|
358
359
|
create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
|
|
@@ -367,6 +368,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
367
368
|
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
368
369
|
compact <peer> [runtime] compact the peer's context (/compact)
|
|
369
370
|
self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
|
|
371
|
+
native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
|
|
370
372
|
`
|
|
371
373
|
|
|
372
374
|
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
@@ -406,8 +408,61 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
406
408
|
} else if (infra.length) {
|
|
407
409
|
out(`onboard --dry-run: would onboard infra runtimes: ${infra.join(', ')}\n`)
|
|
408
410
|
}
|
|
411
|
+
// Memory slot (контракт «Слот памяти», 10.06): optional DEFAULT-YES install of
|
|
412
|
+
// the default provider; its own init runs with INHERITED stdio (the provider
|
|
413
|
+
// owns the install questions). The step is REPORT-ONLY for the exit code — an
|
|
414
|
+
// empty slot is a valid state regardless of why.
|
|
415
|
+
const { onboardMemoryProvider } = await import('../onboard/memory.ts')
|
|
416
|
+
const mem = await onboardMemoryProvider({
|
|
417
|
+
skip: flags['no-memory'] === true,
|
|
418
|
+
package: typeof flags.memory === 'string' ? flags.memory : undefined,
|
|
419
|
+
dryRun: flags['dry-run'] === true,
|
|
420
|
+
env,
|
|
421
|
+
})
|
|
422
|
+
const memLabel = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
|
|
423
|
+
out(`memory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${memLabel})\n`)
|
|
409
424
|
return r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
|
|
410
425
|
}
|
|
426
|
+
case 'native-memory': {
|
|
427
|
+
// The canonized runtime-memory lever (контракт «Слот памяти» §Native-память):
|
|
428
|
+
// off = explicit disable merged into the peer's runtime config files; on =
|
|
429
|
+
// remove the key (restore the runtime's own default). Consumers: the operator
|
|
430
|
+
// and the memory provider's install-time sweep (`--all`). NOT slot-gated here —
|
|
431
|
+
// an explicit operator/provider action; only the BIRTH-time hook is slot-gated.
|
|
432
|
+
const state = positionals[0]
|
|
433
|
+
if (state !== 'off' && state !== 'on') return usage(errOut)
|
|
434
|
+
const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
|
|
435
|
+
if (flags.all !== true && !peerName) return usage(errOut)
|
|
436
|
+
const { applyNativeMemory } = await import('../launch/nativeMemory.ts')
|
|
437
|
+
const index = readPeersIndex({ env })
|
|
438
|
+
const targets = flags.all === true ? index.peers : index.peers.filter(p => p.personality === peerName)
|
|
439
|
+
if (targets.length === 0) {
|
|
440
|
+
errOut(`peer "${peerName ?? ''}" is not in the iapeer peers index\n`)
|
|
441
|
+
return 1
|
|
442
|
+
}
|
|
443
|
+
let failed = false
|
|
444
|
+
for (const p of targets) {
|
|
445
|
+
const outcomes = applyNativeMemory(p.cwd, p.runtimes, state)
|
|
446
|
+
if (outcomes.length === 0) {
|
|
447
|
+
out(`${p.personality}: no claude/codex runtime — skipped\n`)
|
|
448
|
+
continue
|
|
449
|
+
}
|
|
450
|
+
for (const o of outcomes) {
|
|
451
|
+
out(`${p.personality} (${o.runtime}): ${o.state}${o.detail ? ` — ${o.detail}` : ''}\n`)
|
|
452
|
+
if (o.state === 'failed') failed = true
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return failed ? 1 : 0
|
|
456
|
+
}
|
|
457
|
+
case 'status': {
|
|
458
|
+
// Host snapshot (контракт «Слот памяти» §status): version + daemon health +
|
|
459
|
+
// the memory-slot line. Exit 1 iff the daemon is unhealthy (usable as a
|
|
460
|
+
// health gate); an EMPTY memory slot is a valid state and never fails.
|
|
461
|
+
const { hostStatus, formatHostStatus } = await import('../status/index.ts')
|
|
462
|
+
const s = await hostStatus({ env })
|
|
463
|
+
out(formatHostStatus(s))
|
|
464
|
+
return s.daemon.healthy ? 0 : 1
|
|
465
|
+
}
|
|
411
466
|
case 'install-runtime': {
|
|
412
467
|
// §6 onboard a runtime END-TO-END: npx-install the package (auto-resolved from
|
|
413
468
|
// the built-in runtime→package registry, or --package; self-deploys bin +
|
package/src/index.ts
CHANGED
|
@@ -25,5 +25,10 @@ export * from './install/index.ts'
|
|
|
25
25
|
export * from './update/index.ts'
|
|
26
26
|
// Onboard — the host-phase (idempotent marketplace registration in claude + codex).
|
|
27
27
|
export * from './onboard/index.ts'
|
|
28
|
+
// Memory slot — the declarative provider slot (контракт «Слот памяти»): status read + onboard step.
|
|
29
|
+
export * from './status/index.ts'
|
|
30
|
+
export * from './onboard/memory.ts'
|
|
31
|
+
// Native runtime-memory levers (slot contract §Native-память): canonized forms + verb mechanics.
|
|
32
|
+
export * from './launch/nativeMemory.ts'
|
|
28
33
|
export { composeSystemPrompt, gatherPromptInput } from './launch/composeSystemPrompt.ts'
|
|
29
34
|
export type { GatherPromptOptions } from './launch/composeSystemPrompt.ts'
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Native runtime-memory levers — the canonized forms (slot contract §Native-
|
|
2
|
+
// память). The HARD requirement pinned here: NO-CLOBBER — both target files
|
|
3
|
+
// carry foreign blocks (plugin enables, statusline wrappers) that every lever
|
|
4
|
+
// write must preserve byte-meaningfully.
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
7
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import {
|
|
11
|
+
applyNativeMemory,
|
|
12
|
+
claudeSettingsPath,
|
|
13
|
+
codexProjectConfigPath,
|
|
14
|
+
codexGlobalConfigPath,
|
|
15
|
+
preTrustCodexCwd,
|
|
16
|
+
} from './nativeMemory.ts'
|
|
17
|
+
|
|
18
|
+
const dirs: string[] = []
|
|
19
|
+
function mkTmp(): string {
|
|
20
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-natmem-'))
|
|
21
|
+
dirs.push(d)
|
|
22
|
+
return d
|
|
23
|
+
}
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('claude lever (settings.json merge)', () => {
|
|
29
|
+
test('absent file → created with ONLY the lever key', () => {
|
|
30
|
+
const cwd = mkTmp()
|
|
31
|
+
const [o] = applyNativeMemory(cwd, ['claude'], 'off')
|
|
32
|
+
expect(o?.state).toBe('written')
|
|
33
|
+
expect(JSON.parse(readFileSync(claudeSettingsPath(cwd), 'utf8'))).toEqual({ autoMemoryEnabled: false })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('NO-CLOBBER: foreign keys (plugins, statusline) survive off AND on', () => {
|
|
37
|
+
const cwd = mkTmp()
|
|
38
|
+
mkdirSync(join(cwd, '.claude'), { recursive: true })
|
|
39
|
+
const foreign = { enabledPlugins: { 'MergeMind@agfpd': true }, statusLine: { type: 'command', command: 'x' } }
|
|
40
|
+
writeFileSync(claudeSettingsPath(cwd), JSON.stringify(foreign))
|
|
41
|
+
applyNativeMemory(cwd, ['claude'], 'off')
|
|
42
|
+
const afterOff = JSON.parse(readFileSync(claudeSettingsPath(cwd), 'utf8'))
|
|
43
|
+
expect(afterOff).toEqual({ ...foreign, autoMemoryEnabled: false })
|
|
44
|
+
const [on] = applyNativeMemory(cwd, ['claude'], 'on')
|
|
45
|
+
expect(on?.state).toBe('written')
|
|
46
|
+
expect(JSON.parse(readFileSync(claudeSettingsPath(cwd), 'utf8'))).toEqual(foreign) // key REMOVED, default restored
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('idempotent: already-false → already; on with no key → already', () => {
|
|
50
|
+
const cwd = mkTmp()
|
|
51
|
+
applyNativeMemory(cwd, ['claude'], 'off')
|
|
52
|
+
expect(applyNativeMemory(cwd, ['claude'], 'off')[0]?.state).toBe('already')
|
|
53
|
+
applyNativeMemory(cwd, ['claude'], 'on')
|
|
54
|
+
expect(applyNativeMemory(cwd, ['claude'], 'on')[0]?.state).toBe('already')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('non-object settings.json → failed (refuses to clobber), file untouched', () => {
|
|
58
|
+
const cwd = mkTmp()
|
|
59
|
+
mkdirSync(join(cwd, '.claude'), { recursive: true })
|
|
60
|
+
writeFileSync(claudeSettingsPath(cwd), '"just a string"')
|
|
61
|
+
const [o] = applyNativeMemory(cwd, ['claude'], 'off')
|
|
62
|
+
expect(o?.state).toBe('failed')
|
|
63
|
+
expect(readFileSync(claudeSettingsPath(cwd), 'utf8')).toBe('"just a string"')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('codex lever (config.toml [features] merge)', () => {
|
|
68
|
+
test('absent file → [features] section with the lever line', () => {
|
|
69
|
+
const cwd = mkTmp()
|
|
70
|
+
const [o] = applyNativeMemory(cwd, ['codex'], 'off')
|
|
71
|
+
expect(o?.state).toBe('written')
|
|
72
|
+
expect(readFileSync(codexProjectConfigPath(cwd), 'utf8')).toBe('[features]\nmemories = false\n')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('NO-CLOBBER: plugin blocks survive; [features] appended after them (fleet-file shape)', () => {
|
|
76
|
+
const cwd = mkTmp()
|
|
77
|
+
mkdirSync(join(cwd, '.codex'), { recursive: true })
|
|
78
|
+
const fleet = '[plugins."Inter-Agent-Protocol@agfpd"]\nenabled = true\n\n[plugins."MergeMind@agfpd"]\nenabled = true\n'
|
|
79
|
+
writeFileSync(codexProjectConfigPath(cwd), fleet)
|
|
80
|
+
applyNativeMemory(cwd, ['codex'], 'off')
|
|
81
|
+
const text = readFileSync(codexProjectConfigPath(cwd), 'utf8')
|
|
82
|
+
expect(text).toContain('[plugins."Inter-Agent-Protocol@agfpd"]\nenabled = true')
|
|
83
|
+
expect(text).toContain('[plugins."MergeMind@agfpd"]\nenabled = true')
|
|
84
|
+
expect(text).toContain('[features]\nmemories = false')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('existing [features] WITHOUT memories (followed by another section) → inserted INSIDE the section', () => {
|
|
88
|
+
const cwd = mkTmp()
|
|
89
|
+
mkdirSync(join(cwd, '.codex'), { recursive: true })
|
|
90
|
+
writeFileSync(codexProjectConfigPath(cwd), '[features]\nother = true\n\n[plugins."X@y"]\nenabled = true\n')
|
|
91
|
+
applyNativeMemory(cwd, ['codex'], 'off')
|
|
92
|
+
const text = readFileSync(codexProjectConfigPath(cwd), 'utf8')
|
|
93
|
+
// the lever line lands in [features], NOT in [plugins."X@y"]
|
|
94
|
+
expect(text.indexOf('memories = false')).toBeLessThan(text.indexOf('[plugins."X@y"]'))
|
|
95
|
+
expect(text).toContain('other = true')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('memories = true → replaced with false; already false → already', () => {
|
|
99
|
+
const cwd = mkTmp()
|
|
100
|
+
mkdirSync(join(cwd, '.codex'), { recursive: true })
|
|
101
|
+
writeFileSync(codexProjectConfigPath(cwd), '[features]\nmemories = true\n')
|
|
102
|
+
expect(applyNativeMemory(cwd, ['codex'], 'off')[0]?.state).toBe('written')
|
|
103
|
+
expect(readFileSync(codexProjectConfigPath(cwd), 'utf8')).toContain('memories = false')
|
|
104
|
+
expect(applyNativeMemory(cwd, ['codex'], 'off')[0]?.state).toBe('already')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('on → the memories line is REMOVED (default restored), section + foreign lines kept', () => {
|
|
108
|
+
const cwd = mkTmp()
|
|
109
|
+
mkdirSync(join(cwd, '.codex'), { recursive: true })
|
|
110
|
+
writeFileSync(codexProjectConfigPath(cwd), '[features]\nmemories = false\nother = true\n')
|
|
111
|
+
const [o] = applyNativeMemory(cwd, ['codex'], 'on')
|
|
112
|
+
expect(o?.state).toBe('written')
|
|
113
|
+
const text = readFileSync(codexProjectConfigPath(cwd), 'utf8')
|
|
114
|
+
expect(text).not.toContain('memories')
|
|
115
|
+
expect(text).toContain('other = true')
|
|
116
|
+
expect(applyNativeMemory(cwd, ['codex'], 'on')[0]?.state).toBe('already')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('applyNativeMemory runtime dispatch', () => {
|
|
121
|
+
test('both runtimes → both levers; router-only runtimes → no outcomes', () => {
|
|
122
|
+
const cwd = mkTmp()
|
|
123
|
+
const both = applyNativeMemory(cwd, ['claude', 'codex'], 'off')
|
|
124
|
+
expect(both.map(o => o.runtime).sort()).toEqual(['claude', 'codex'])
|
|
125
|
+
expect(applyNativeMemory(mkTmp(), ['telegram', 'notifier'], 'off')).toEqual([])
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('preTrustCodexCwd (birth-time trust, codex global config)', () => {
|
|
130
|
+
test('appends a [projects] block once; NO-CLOBBER of existing content; idempotent', () => {
|
|
131
|
+
const home = mkTmp()
|
|
132
|
+
const env = { HOME: home } as NodeJS.ProcessEnv
|
|
133
|
+
mkdirSync(join(home, '.codex'), { recursive: true })
|
|
134
|
+
const existing = '[projects."/already/trusted"]\ntrust_level = "trusted"\n'
|
|
135
|
+
writeFileSync(codexGlobalConfigPath(env), existing)
|
|
136
|
+
const first = preTrustCodexCwd('/peers/newborn', env)
|
|
137
|
+
expect(first.state).toBe('written')
|
|
138
|
+
const text = readFileSync(codexGlobalConfigPath(env), 'utf8')
|
|
139
|
+
expect(text).toContain(existing.trim())
|
|
140
|
+
expect(text).toContain('[projects."/peers/newborn"]\ntrust_level = "trusted"')
|
|
141
|
+
expect(preTrustCodexCwd('/peers/newborn', env).state).toBe('already')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('absent global config → created with just the block', () => {
|
|
145
|
+
const home = mkTmp()
|
|
146
|
+
const env = { HOME: home } as NodeJS.ProcessEnv
|
|
147
|
+
expect(preTrustCodexCwd('/peers/first', env).state).toBe('written')
|
|
148
|
+
expect(readFileSync(codexGlobalConfigPath(env), 'utf8')).toBe('[projects."/peers/first"]\ntrust_level = "trusted"\n')
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Native runtime-memory levers (контракт «Слот памяти» §Native-память рантаймов
|
|
2
|
+
// при занятом слоте, agreed 10.06). The CORE canonizes the lever FORMS — runtime
|
|
3
|
+
// knowledge lives in the runtime layer, like deliveryMarkers/buildArgv:
|
|
4
|
+
//
|
|
5
|
+
// claude: merge `"autoMemoryEnabled": false` into `<cwd>/.claude/settings.json`
|
|
6
|
+
// codex: merge `memories = false` into the `[features]` section of
|
|
7
|
+
// `<cwd>/.codex/config.toml`
|
|
8
|
+
//
|
|
9
|
+
// Both files CARRY FOREIGN BLOCKS (plugin enables, statusline wrappers, …) —
|
|
10
|
+
// every write here is a NO-CLOBBER merge (parse/patch + atomic temp+rename),
|
|
11
|
+
// never a rewrite. Forms verified live on the fleet (12/12 + behavioral smoke
|
|
12
|
+
// 09.06; codex reads project-local config when the cwd is TRUSTED — see
|
|
13
|
+
// preTrustCodexCwd).
|
|
14
|
+
//
|
|
15
|
+
// `off` writes the explicit disable; `on` REMOVES the key (restores the
|
|
16
|
+
// runtime's own default rather than baking an explicit enable — the core has
|
|
17
|
+
// no opinion beyond "parallel store off while a memory provider owns the host").
|
|
18
|
+
//
|
|
19
|
+
// Consumers: the operator verb `iapeer native-memory <off|on>`, the provider's
|
|
20
|
+
// install-time sweep (calls the verb), and provisionPeer's birth-time hook
|
|
21
|
+
// (slot-gated, see provision/index.ts).
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs'
|
|
24
|
+
import { dirname, join } from 'path'
|
|
25
|
+
import { homedir } from 'os'
|
|
26
|
+
import type { Runtime } from '../core/constants.ts'
|
|
27
|
+
import { writeFileAtomic } from '../storage/index.ts'
|
|
28
|
+
|
|
29
|
+
export type NativeMemoryState = 'off' | 'on'
|
|
30
|
+
|
|
31
|
+
export interface LeverOutcome {
|
|
32
|
+
runtime: 'claude' | 'codex'
|
|
33
|
+
path: string
|
|
34
|
+
state: 'written' | 'already' | 'failed'
|
|
35
|
+
detail?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── claude: <cwd>/.claude/settings.json — JSON merge ───────────────────────
|
|
39
|
+
|
|
40
|
+
export function claudeSettingsPath(cwd: string): string {
|
|
41
|
+
return join(cwd, '.claude', 'settings.json')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function applyClaudeLever(cwd: string, state: NativeMemoryState): LeverOutcome {
|
|
45
|
+
const path = claudeSettingsPath(cwd)
|
|
46
|
+
try {
|
|
47
|
+
let obj: Record<string, unknown> = {}
|
|
48
|
+
if (existsSync(path)) {
|
|
49
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
50
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
51
|
+
return { runtime: 'claude', path, state: 'failed', detail: 'settings.json is not a JSON object — refusing to clobber' }
|
|
52
|
+
}
|
|
53
|
+
obj = raw as Record<string, unknown>
|
|
54
|
+
}
|
|
55
|
+
if (state === 'off') {
|
|
56
|
+
if (obj.autoMemoryEnabled === false) return { runtime: 'claude', path, state: 'already' }
|
|
57
|
+
obj.autoMemoryEnabled = false
|
|
58
|
+
} else {
|
|
59
|
+
if (!('autoMemoryEnabled' in obj)) return { runtime: 'claude', path, state: 'already' }
|
|
60
|
+
delete obj.autoMemoryEnabled // restore the runtime's own default
|
|
61
|
+
}
|
|
62
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
63
|
+
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`)
|
|
64
|
+
return { runtime: 'claude', path, state: 'written' }
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return { runtime: 'claude', path, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── codex: <cwd>/.codex/config.toml — section-scoped line merge ─────────────
|
|
71
|
+
|
|
72
|
+
export function codexProjectConfigPath(cwd: string): string {
|
|
73
|
+
return join(cwd, '.codex', 'config.toml')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Locate the `[features]` section bounds: [startLine, endLine) of its BODY
|
|
77
|
+
* (after the header, up to the next `[…]` header or EOF). null = no section. */
|
|
78
|
+
function featuresBounds(lines: string[]): { header: number; start: number; end: number } | null {
|
|
79
|
+
const header = lines.findIndex(l => l.trim() === '[features]')
|
|
80
|
+
if (header < 0) return null
|
|
81
|
+
let end = lines.length
|
|
82
|
+
for (let i = header + 1; i < lines.length; i++) {
|
|
83
|
+
if (/^\s*\[/.test(lines[i]!)) {
|
|
84
|
+
end = i
|
|
85
|
+
break
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { header, start: header + 1, end }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function applyCodexLever(cwd: string, state: NativeMemoryState): LeverOutcome {
|
|
92
|
+
const path = codexProjectConfigPath(cwd)
|
|
93
|
+
try {
|
|
94
|
+
const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
95
|
+
const lines = text.length ? text.split('\n') : []
|
|
96
|
+
const bounds = featuresBounds(lines)
|
|
97
|
+
const memRe = /^\s*memories\s*=/
|
|
98
|
+
if (state === 'off') {
|
|
99
|
+
if (bounds) {
|
|
100
|
+
const idx = lines.slice(bounds.start, bounds.end).findIndex(l => memRe.test(l))
|
|
101
|
+
if (idx >= 0) {
|
|
102
|
+
const abs = bounds.start + idx
|
|
103
|
+
if (/^\s*memories\s*=\s*false\s*$/.test(lines[abs]!)) return { runtime: 'codex', path, state: 'already' }
|
|
104
|
+
lines[abs] = 'memories = false'
|
|
105
|
+
} else {
|
|
106
|
+
lines.splice(bounds.start, 0, 'memories = false')
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
if (lines.length && lines[lines.length - 1]!.trim() !== '') lines.push('')
|
|
110
|
+
lines.push('[features]', 'memories = false')
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
if (!bounds) return { runtime: 'codex', path, state: 'already' }
|
|
114
|
+
const idx = lines.slice(bounds.start, bounds.end).findIndex(l => memRe.test(l))
|
|
115
|
+
if (idx < 0) return { runtime: 'codex', path, state: 'already' }
|
|
116
|
+
lines.splice(bounds.start + idx, 1) // restore the runtime's own default
|
|
117
|
+
}
|
|
118
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
119
|
+
const outText = lines.join('\n')
|
|
120
|
+
writeFileAtomic(path, outText.endsWith('\n') ? outText : `${outText}\n`)
|
|
121
|
+
return { runtime: 'codex', path, state: 'written' }
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { runtime: 'codex', path, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── codex trust (birth-time only) ───────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/** The codex GLOBAL config (`~/.codex/config.toml`) — where trust lives as
|
|
130
|
+
* `[projects."<path>"] trust_level = "trusted"` blocks. */
|
|
131
|
+
export function codexGlobalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
132
|
+
const home = env.HOME?.trim() || homedir()
|
|
133
|
+
return join(home, '.codex', 'config.toml')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Pre-trust a NEWBORN codex peer's cwd by appending its `[projects."<cwd>"]`
|
|
138
|
+
* block to the codex global config (no-clobber: only when no block for this
|
|
139
|
+
* path exists). Closes the contract requirement «рычаг действует с ПЕРВОЙ
|
|
140
|
+
* сессии» DETERMINISTICALLY: codex reads project-local config only for a
|
|
141
|
+
* trusted cwd, and without pre-trust the first session's trust is granted
|
|
142
|
+
* mid-boot by the adapter's dialog auto-accept — whether the project config is
|
|
143
|
+
* (re)read after that grant within the same run is codex-internal. Birth-time
|
|
144
|
+
* only — existing peers are already trusted through their boot history.
|
|
145
|
+
*/
|
|
146
|
+
export function preTrustCodexCwd(cwd: string, env: NodeJS.ProcessEnv = process.env): LeverOutcome {
|
|
147
|
+
const path = codexGlobalConfigPath(env)
|
|
148
|
+
try {
|
|
149
|
+
// Codex keys trust on the RESOLVED real path (live fact, 10.06 acceptance: the
|
|
150
|
+
// /tmp → /private/tmp symlink made a literal-path entry MISS and left the lever
|
|
151
|
+
// inert — trust entries written by codex itself are all /private/tmp/...).
|
|
152
|
+
let real = cwd
|
|
153
|
+
try {
|
|
154
|
+
real = realpathSync(cwd)
|
|
155
|
+
} catch {
|
|
156
|
+
/* cwd not on disk yet → keep as given */
|
|
157
|
+
}
|
|
158
|
+
const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
159
|
+
if (text.includes(`[projects."${real}"]`)) return { runtime: 'codex', path, state: 'already' }
|
|
160
|
+
const block = `${text.length && !text.endsWith('\n') ? '\n' : ''}${text.trim().length ? '\n' : ''}[projects."${real}"]\ntrust_level = "trusted"\n`
|
|
161
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
162
|
+
writeFileAtomic(path, text + block)
|
|
163
|
+
return { runtime: 'codex', path, state: 'written' }
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return { runtime: 'codex', path, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── public entry ────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Apply the native-memory lever for one peer cwd across its declared runtimes
|
|
173
|
+
* (∩ {claude, codex} — other runtimes carry no native memory). Idempotent;
|
|
174
|
+
* every outcome is reported (a 'failed' lever never throws).
|
|
175
|
+
*/
|
|
176
|
+
export function applyNativeMemory(
|
|
177
|
+
cwd: string,
|
|
178
|
+
runtimes: readonly Runtime[],
|
|
179
|
+
state: NativeMemoryState,
|
|
180
|
+
): LeverOutcome[] {
|
|
181
|
+
const out: LeverOutcome[] = []
|
|
182
|
+
if (runtimes.includes('claude')) out.push(applyClaudeLever(cwd, state))
|
|
183
|
+
if (runtimes.includes('codex')) out.push(applyCodexLever(cwd, state))
|
|
184
|
+
return out
|
|
185
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// onboardMemoryProvider — the host-phase memory-slot step (контракт «Слот
|
|
2
|
+
// памяти»). Default-YES, report-only for the exit code; the PROVIDER writes the
|
|
3
|
+
// slot declaration (the step verifies it did). All writes under a temp
|
|
4
|
+
// IAPEER_ROOT — never the live host.
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
7
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { coreKnownInitArgs, onboardMemoryProvider, DEFAULT_MEMORY_PACKAGE } from './memory.ts'
|
|
11
|
+
import { memoryProviderPath } from '../status/index.ts'
|
|
12
|
+
|
|
13
|
+
const dirs: string[] = []
|
|
14
|
+
function mkTmp(): string {
|
|
15
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-memstep-'))
|
|
16
|
+
dirs.push(d)
|
|
17
|
+
return d
|
|
18
|
+
}
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function envFor(root: string): NodeJS.ProcessEnv {
|
|
24
|
+
return { IAPEER_ROOT: root } as NodeJS.ProcessEnv
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SLOT = {
|
|
28
|
+
provider: 'iapeer-memory',
|
|
29
|
+
package: DEFAULT_MEMORY_PACKAGE,
|
|
30
|
+
version: '0.1.0',
|
|
31
|
+
registeredAt: '2026-06-10T00:00:00Z',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function registry(root: string, naturals: string[]): void {
|
|
35
|
+
writeFileSync(
|
|
36
|
+
join(root, 'peers-profiles.json'),
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
version: 2,
|
|
39
|
+
peers: [
|
|
40
|
+
...naturals.map(p => ({ personality: p, runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'natural', cwd: `/tmp/${p}` })),
|
|
41
|
+
{ personality: 'bot', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/tmp/bot' },
|
|
42
|
+
],
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('coreKnownInitArgs (the EXHAUSTIVE v1 passthrough list)', () => {
|
|
48
|
+
test('exactly one natural peer → --human <personality>', () => {
|
|
49
|
+
const root = mkTmp()
|
|
50
|
+
registry(root, ['arthur'])
|
|
51
|
+
expect(coreKnownInitArgs(envFor(root))).toEqual(['--human', 'arthur'])
|
|
52
|
+
})
|
|
53
|
+
test('zero or many naturals → pass NOTHING (the provider asks itself)', () => {
|
|
54
|
+
const none = mkTmp()
|
|
55
|
+
registry(none, [])
|
|
56
|
+
expect(coreKnownInitArgs(envFor(none))).toEqual([])
|
|
57
|
+
const many = mkTmp()
|
|
58
|
+
registry(many, ['arthur', 'maria'])
|
|
59
|
+
expect(coreKnownInitArgs(envFor(many))).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
test('legacy intelligence "human" normalizes to natural (read-compat)', () => {
|
|
62
|
+
const root = mkTmp()
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(root, 'peers-profiles.json'),
|
|
65
|
+
JSON.stringify({ version: 2, peers: [{ personality: 'arthur', runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'human', cwd: '/tmp/a' }] }),
|
|
66
|
+
)
|
|
67
|
+
expect(coreKnownInitArgs(envFor(root))).toEqual(['--human', 'arthur'])
|
|
68
|
+
})
|
|
69
|
+
test('no registry at all → [] (never throws)', () => {
|
|
70
|
+
expect(coreKnownInitArgs(envFor(mkTmp()))).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('onboardMemoryProvider', () => {
|
|
75
|
+
test('--no-memory → skipped-flag, init never invoked', async () => {
|
|
76
|
+
const root = mkTmp()
|
|
77
|
+
let invoked = 0
|
|
78
|
+
const r = await onboardMemoryProvider({
|
|
79
|
+
skip: true,
|
|
80
|
+
env: envFor(root),
|
|
81
|
+
runInit: () => ((invoked++), { status: 0, unavailable: false }),
|
|
82
|
+
})
|
|
83
|
+
expect(r.state).toBe('skipped-flag')
|
|
84
|
+
expect(invoked).toBe(0)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('same package already in the slot → already (idempotent no-op, no init call)', async () => {
|
|
88
|
+
const root = mkTmp()
|
|
89
|
+
writeFileSync(memoryProviderPath(envFor(root)), JSON.stringify(SLOT))
|
|
90
|
+
let invoked = 0
|
|
91
|
+
const r = await onboardMemoryProvider({
|
|
92
|
+
env: envFor(root),
|
|
93
|
+
runInit: () => ((invoked++), { status: 0, unavailable: false }),
|
|
94
|
+
})
|
|
95
|
+
expect(r.state).toBe('already')
|
|
96
|
+
expect(r.provider?.provider).toBe('iapeer-memory')
|
|
97
|
+
expect(invoked).toBe(0)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('slot occupied by ANOTHER provider → refused-foreign (never silent overwrite)', async () => {
|
|
101
|
+
const root = mkTmp()
|
|
102
|
+
writeFileSync(memoryProviderPath(envFor(root)), JSON.stringify({ ...SLOT, provider: 'other-mem', package: '@x/other' }))
|
|
103
|
+
const r = await onboardMemoryProvider({ env: envFor(root), runInit: () => ({ status: 0, unavailable: false }) })
|
|
104
|
+
expect(r.state).toBe('refused-foreign')
|
|
105
|
+
expect(r.detail).toMatch(/occupied .*other-mem.*uninstall/)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('dry-run → reports the exact would-be command incl. --human passthrough', async () => {
|
|
109
|
+
const root = mkTmp()
|
|
110
|
+
registry(root, ['arthur'])
|
|
111
|
+
const r = await onboardMemoryProvider({ dryRun: true, env: envFor(root) })
|
|
112
|
+
expect(r.state).toBe('dry-run')
|
|
113
|
+
expect(r.detail).toBe(`would run: npx -y ${DEFAULT_MEMORY_PACKAGE} init --human arthur`)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('package unavailable → skipped-unavailable (SOFT skip — release order never blocks onboard)', async () => {
|
|
117
|
+
const root = mkTmp()
|
|
118
|
+
registry(root, [])
|
|
119
|
+
const r = await onboardMemoryProvider({ env: envFor(root), runInit: () => ({ status: 1, unavailable: true }) })
|
|
120
|
+
expect(r.state).toBe('skipped-unavailable')
|
|
121
|
+
expect(r.detail).toMatch(/not available/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('init ok + provider declared the slot → installed (slot read back)', async () => {
|
|
125
|
+
const root = mkTmp()
|
|
126
|
+
registry(root, ['arthur'])
|
|
127
|
+
const env = envFor(root)
|
|
128
|
+
const calls: string[][] = []
|
|
129
|
+
const r = await onboardMemoryProvider({
|
|
130
|
+
env,
|
|
131
|
+
runInit: (pkg, args) => {
|
|
132
|
+
calls.push([pkg, ...args])
|
|
133
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify(SLOT)) // the PROVIDER writes the slot
|
|
134
|
+
return { status: 0, unavailable: false }
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
expect(r.state).toBe('installed')
|
|
138
|
+
expect(r.provider?.version).toBe('0.1.0')
|
|
139
|
+
expect(calls).toEqual([[DEFAULT_MEMORY_PACKAGE, 'init', '--human', 'arthur']])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('init ok but slot NOT declared → provider-init-failed (its contract duty, surfaced)', async () => {
|
|
143
|
+
const root = mkTmp()
|
|
144
|
+
registry(root, [])
|
|
145
|
+
const r = await onboardMemoryProvider({ env: envFor(root), runInit: () => ({ status: 0, unavailable: false }) })
|
|
146
|
+
expect(r.state).toBe('provider-init-failed')
|
|
147
|
+
expect(r.detail).toMatch(/did not declare/)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('init non-zero (e.g. non-tty refusal) → provider-init-failed with the exit code', async () => {
|
|
151
|
+
const root = mkTmp()
|
|
152
|
+
registry(root, [])
|
|
153
|
+
const r = await onboardMemoryProvider({ env: envFor(root), runInit: () => ({ status: 2, unavailable: false }) })
|
|
154
|
+
expect(r.state).toBe('provider-init-failed')
|
|
155
|
+
expect(r.detail).toMatch(/exited 2/)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Memory-slot onboard step (docs/Слот памяти — контракт memory provider.md,
|
|
2
|
+
// agreed with iapeer-memory + boris 10.06). Host-phase, optional, DEFAULT-YES:
|
|
3
|
+
// install the default memory provider package and run ITS OWN init-verb — the
|
|
4
|
+
// provider writes the slot declaration and deploys its surfaces; the core never
|
|
5
|
+
// writes the slot itself. The provider OWNS the install questions (two-mode
|
|
6
|
+
// init): we run it with INHERITED stdio so its tty interactive happens inside
|
|
7
|
+
// the onboard host phase, exactly once. The core passes as flags ONLY facts it
|
|
8
|
+
// owns by its own contracts — v1 list is exactly one: `--human <personality>`
|
|
9
|
+
// when EXACTLY ONE natural peer is registered.
|
|
10
|
+
//
|
|
11
|
+
// Outcome semantics: this step NEVER fails the onboard exit code — an empty
|
|
12
|
+
// slot is a fully valid state regardless of why (skipped / package not yet
|
|
13
|
+
// published / provider refused non-tty). Outcomes are reported, not enforced.
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from 'child_process'
|
|
16
|
+
import { normalizeIntelligenceValue } from '../core/constants.ts'
|
|
17
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
18
|
+
import { readMemoryProvider, type MemoryProvider } from '../status/index.ts'
|
|
19
|
+
|
|
20
|
+
/** The distribution default provider (Артур: memory is a first-class core option). */
|
|
21
|
+
export const DEFAULT_MEMORY_PACKAGE = '@agfpd/iapeer-memory'
|
|
22
|
+
|
|
23
|
+
export interface MemoryOnboardOptions {
|
|
24
|
+
/** --no-memory: skip the step entirely. */
|
|
25
|
+
skip?: boolean
|
|
26
|
+
/** --memory <pkg>: override the provider package (default @agfpd/iapeer-memory). */
|
|
27
|
+
package?: string
|
|
28
|
+
dryRun?: boolean
|
|
29
|
+
env?: NodeJS.ProcessEnv
|
|
30
|
+
/** Injectable runner (tests). Default: availability probe + `npx -y <pkg> init …`
|
|
31
|
+
* with inherited stdio. */
|
|
32
|
+
runInit?: (pkg: string, args: string[], env: NodeJS.ProcessEnv) => { status: number | null; unavailable: boolean }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MemoryOnboardResult {
|
|
36
|
+
state:
|
|
37
|
+
| 'installed' // provider init succeeded and declared the slot
|
|
38
|
+
| 'already' // slot already occupied by the SAME package — idempotent no-op
|
|
39
|
+
| 'skipped-flag' // --no-memory
|
|
40
|
+
| 'skipped-unavailable' // package not published / no network — soft skip (contract)
|
|
41
|
+
| 'refused-foreign' // slot occupied by ANOTHER provider — explicit refusal
|
|
42
|
+
| 'provider-init-failed' // provider init ran and exited non-zero / declared nothing
|
|
43
|
+
| 'dry-run'
|
|
44
|
+
provider: MemoryProvider | null
|
|
45
|
+
detail?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultRunInit(
|
|
49
|
+
pkg: string,
|
|
50
|
+
args: string[],
|
|
51
|
+
env: NodeJS.ProcessEnv,
|
|
52
|
+
): { status: number | null; unavailable: boolean } {
|
|
53
|
+
// Availability probe FIRST (cheap, side-effect-free): an unpublished package /
|
|
54
|
+
// no network → soft-skip per the contract (the provider's release order must
|
|
55
|
+
// never block the core's onboard).
|
|
56
|
+
const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env })
|
|
57
|
+
if (probe.status !== 0) return { status: probe.status, unavailable: true }
|
|
58
|
+
// INHERITED stdio — the provider owns its install questions (tty interactive
|
|
59
|
+
// happens here, once). npx bin-name nuance (cf. the 0.2.9 update грабли): with
|
|
60
|
+
// the provider bin already on PATH npx runs the INSTALLED one — acceptable for
|
|
61
|
+
// an idempotent init (unlike the self-update case where it was a structural bug).
|
|
62
|
+
const r = spawnSync('npx', ['-y', pkg, ...args], { stdio: 'inherit', env })
|
|
63
|
+
return { status: r.status, unavailable: false }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The exhaustive v1 list of core-known facts passed to the provider init:
|
|
67
|
+
* `--human <personality>` iff EXACTLY ONE natural peer is registered (the core
|
|
68
|
+
* has no separate owner-name config — the owner IS the natural peer). */
|
|
69
|
+
export function coreKnownInitArgs(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
70
|
+
try {
|
|
71
|
+
const naturals = readPeersIndex({ env }).peers.filter(
|
|
72
|
+
p => normalizeIntelligenceValue(p.intelligence) === 'natural',
|
|
73
|
+
)
|
|
74
|
+
return naturals.length === 1 ? ['--human', naturals[0]!.personality] : []
|
|
75
|
+
} catch {
|
|
76
|
+
return [] // no registry / unreadable → pass nothing, the provider asks itself
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function onboardMemoryProvider(opts: MemoryOnboardOptions = {}): Promise<MemoryOnboardResult> {
|
|
81
|
+
const env = opts.env ?? process.env
|
|
82
|
+
const pkg = opts.package?.trim() || DEFAULT_MEMORY_PACKAGE
|
|
83
|
+
const existing = readMemoryProvider(env)
|
|
84
|
+
if (opts.skip) return { state: 'skipped-flag', provider: existing }
|
|
85
|
+
if (existing) {
|
|
86
|
+
if (existing.package === pkg) return { state: 'already', provider: existing }
|
|
87
|
+
// Never silently install over an occupied slot (contract: ЗАПРЕЩЕНО).
|
|
88
|
+
return {
|
|
89
|
+
state: 'refused-foreign',
|
|
90
|
+
provider: existing,
|
|
91
|
+
detail: `slot is occupied by "${existing.provider}" (${existing.package}) — uninstall it first`,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const args = ['init', ...coreKnownInitArgs(env)]
|
|
95
|
+
if (opts.dryRun) {
|
|
96
|
+
return { state: 'dry-run', provider: null, detail: `would run: npx -y ${pkg} ${args.join(' ')}` }
|
|
97
|
+
}
|
|
98
|
+
const run = opts.runInit ?? defaultRunInit
|
|
99
|
+
const r = run(pkg, args, env)
|
|
100
|
+
if (r.unavailable) {
|
|
101
|
+
return {
|
|
102
|
+
state: 'skipped-unavailable',
|
|
103
|
+
provider: null,
|
|
104
|
+
detail: `${pkg} is not available (not published yet / no network) — install later: npx ${pkg} init`,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (r.status !== 0) {
|
|
108
|
+
return {
|
|
109
|
+
state: 'provider-init-failed',
|
|
110
|
+
provider: readMemoryProvider(env),
|
|
111
|
+
detail: `provider init exited ${r.status ?? 'null'}`,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// The PROVIDER declares the slot; verify it actually did (its contract duty).
|
|
115
|
+
const after = readMemoryProvider(env)
|
|
116
|
+
if (!after) {
|
|
117
|
+
return {
|
|
118
|
+
state: 'provider-init-failed',
|
|
119
|
+
provider: null,
|
|
120
|
+
detail: 'provider init succeeded but did not declare the slot (memory-provider.json missing)',
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { state: 'installed', provider: after }
|
|
124
|
+
}
|
package/src/provision/index.ts
CHANGED
|
@@ -91,6 +91,40 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
91
91
|
runtimeBin,
|
|
92
92
|
warn: opts.warn,
|
|
93
93
|
})
|
|
94
|
+
// Birth-time native-memory lever (контракт «Слот памяти» §Native-память рантаймов):
|
|
95
|
+
// an OCCUPIED memory slot gates the AUTOMATIC lever at peer birth — zero window of
|
|
96
|
+
// a parallel, uncurated native store (the criterion). Empty slot → native memory is
|
|
97
|
+
// legitimate, untouched. Best-effort with a LOUD warn (a lever hiccup must not kill
|
|
98
|
+
// the provision — the operator verb `iapeer native-memory off` is the repair).
|
|
99
|
+
try {
|
|
100
|
+
const { readMemoryProvider } = await import('../status/index.ts')
|
|
101
|
+
if (readMemoryProvider(env)) {
|
|
102
|
+
const { applyNativeMemory, preTrustCodexCwd } = await import('../launch/nativeMemory.ts')
|
|
103
|
+
for (const o of applyNativeMemory(cwd, profile.runtimes, 'off')) {
|
|
104
|
+
if (o.state === 'failed') {
|
|
105
|
+
opts.warn?.(
|
|
106
|
+
`native-memory lever (${o.runtime}) FAILED for "${profile.personality}": ${o.detail} — ` +
|
|
107
|
+
`the peer may accumulate a parallel native store; repair: iapeer native-memory off --peer ${profile.personality}`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Codex reads project-local config only for a TRUSTED cwd — pre-trust the
|
|
112
|
+
// newborn so the lever acts from the FIRST session (contract requirement),
|
|
113
|
+
// not from whenever the boot dialog's trust grant takes effect.
|
|
114
|
+
if (profile.runtimes.includes('codex')) {
|
|
115
|
+
const t = preTrustCodexCwd(cwd, env)
|
|
116
|
+
if (t.state === 'failed') {
|
|
117
|
+
opts.warn?.(
|
|
118
|
+
`codex pre-trust FAILED for "${profile.personality}": ${t.detail} — ` +
|
|
119
|
+
`the lever may be inert until the first boot's trust dialog`,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
opts.warn?.(`native-memory birth-time hook failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
94
128
|
// wake_policy:"ephemeral" sanity (M2 edge cases — warn, don't refuse: the policy
|
|
95
129
|
// lives in the hand-editable local profile, provision just surfaces the mismatch):
|
|
96
130
|
// • + interfaces.telegram: ephemeral WINS in resolveWakeMode (explicit policy
|
|
@@ -158,3 +158,44 @@ describe('provisionPeer ephemeral warnings', () => {
|
|
|
158
158
|
expect(warns.some(w => /ephemeral/.test(w) && /inert|launchd/i.test(w))).toBe(true)
|
|
159
159
|
})
|
|
160
160
|
})
|
|
161
|
+
|
|
162
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
// Birth-time native-memory lever (slot contract §Native-память): an OCCUPIED
|
|
164
|
+
// slot gates the automatic lever at provision; empty slot → untouched.
|
|
165
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe('provisionPeer birth-time native-memory lever', () => {
|
|
168
|
+
test('slot OCCUPIED → claude+codex levers written + codex cwd pre-trusted', async () => {
|
|
169
|
+
const root = mkTmp()
|
|
170
|
+
const env = envFor(root)
|
|
171
|
+
// occupy the slot (as the provider's init would)
|
|
172
|
+
mkdirSync(join(root, 'iapeer'), { recursive: true })
|
|
173
|
+
writeFileSync(
|
|
174
|
+
join(root, 'iapeer', 'memory-provider.json'),
|
|
175
|
+
JSON.stringify({ provider: 'iapeer-memory', package: '@agfpd/iapeer-memory', version: '0.1.0', registeredAt: 'x' }),
|
|
176
|
+
)
|
|
177
|
+
const cwd = join(root, 'wnat')
|
|
178
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
179
|
+
writeFileSync(
|
|
180
|
+
peerProfilePath(cwd),
|
|
181
|
+
JSON.stringify({ personality: 'wnat', runtime: 'claude', runtimes: ['claude', 'codex'] }),
|
|
182
|
+
)
|
|
183
|
+
await provisionPeer({ cwd, runtime: 'claude', env })
|
|
184
|
+
expect(JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8')).autoMemoryEnabled).toBe(false)
|
|
185
|
+
expect(readFileSync(join(cwd, '.codex', 'config.toml'), 'utf8')).toContain('memories = false')
|
|
186
|
+
// codex newborn pre-trusted in the GLOBAL codex config (HOME-scoped → temp root).
|
|
187
|
+
// Codex keys trust on the RESOLVED real path (macOS /var → /private/var), so the
|
|
188
|
+
// entry must carry realpath(cwd) — the literal-path miss was a live-caught bug.
|
|
189
|
+
const { realpathSync } = await import('fs')
|
|
190
|
+
expect(readFileSync(join(root, '.codex', 'config.toml'), 'utf8')).toContain(`[projects."${realpathSync(cwd)}"]`)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('slot EMPTY → native memory untouched (legitimate without a provider)', async () => {
|
|
194
|
+
const root = mkTmp()
|
|
195
|
+
const env = envFor(root)
|
|
196
|
+
const cwd = join(root, 'wbare')
|
|
197
|
+
await provisionPeer({ cwd, runtime: 'claude', env })
|
|
198
|
+
expect(existsSync(join(cwd, '.claude', 'settings.json'))).toBe(false)
|
|
199
|
+
expect(existsSync(join(cwd, '.codex', 'config.toml'))).toBe(false)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Status — the host-snapshot verb: installed-binary version, daemon health, and
|
|
2
|
+
// the MEMORY SLOT line (docs/Слот памяти — контракт memory provider.md). The slot
|
|
3
|
+
// is DECLARATIVE: a root file `~/.iapeer/memory-provider.json` written (atomically)
|
|
4
|
+
// by the PROVIDER's own init/uninstall — the core only ever READS it. An absent or
|
|
5
|
+
// unreadable file is the EMPTY slot — a fully valid state (bare core), never an
|
|
6
|
+
// error (fail-open). The core never acts on heartbeat staleness — it only REPORTS
|
|
7
|
+
// it (healing the provider's daemon is the provider's job, their ADR-010).
|
|
8
|
+
|
|
9
|
+
import { readFileSync, statSync } from 'fs'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { IAPEER_VERSION } from '../core/version.ts'
|
|
12
|
+
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
13
|
+
import { daemonDiscoveryPath } from '../daemon/index.ts'
|
|
14
|
+
import { waitForDaemonHealthy } from '../update/index.ts'
|
|
15
|
+
|
|
16
|
+
/** The slot-declaration filename in the storage root (next to peers-profiles.json). */
|
|
17
|
+
export const MEMORY_PROVIDER_FILE = 'memory-provider.json'
|
|
18
|
+
|
|
19
|
+
export interface MemoryProvider {
|
|
20
|
+
/** Provider name occupying the slot (e.g. "iapeer-memory"). */
|
|
21
|
+
provider: string
|
|
22
|
+
/** npm package of the provider (e.g. "@agfpd/iapeer-memory"). */
|
|
23
|
+
package: string
|
|
24
|
+
version: string
|
|
25
|
+
registeredAt: string
|
|
26
|
+
/** Optional liveness proxy: an absolute path whose mtime the provider's daemon
|
|
27
|
+
* refreshes. status reports its age; the core takes NO action on staleness. */
|
|
28
|
+
heartbeat?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function memoryProviderPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
32
|
+
return join(resolveGlobalRoot(env), MEMORY_PROVIDER_FILE)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read the memory-slot declaration. null = EMPTY slot (absent / unreadable /
|
|
37
|
+
* schema-invalid file) — a valid state, so this NEVER throws (fail-open to bare).
|
|
38
|
+
*/
|
|
39
|
+
export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): MemoryProvider | null {
|
|
40
|
+
try {
|
|
41
|
+
const raw = JSON.parse(readFileSync(memoryProviderPath(env), 'utf8')) as unknown
|
|
42
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
43
|
+
const o = raw as Record<string, unknown>
|
|
44
|
+
if (typeof o.provider !== 'string' || !o.provider.trim()) return null
|
|
45
|
+
if (typeof o.package !== 'string' || !o.package.trim()) return null
|
|
46
|
+
if (typeof o.version !== 'string' || !o.version.trim()) return null
|
|
47
|
+
return {
|
|
48
|
+
provider: o.provider.trim(),
|
|
49
|
+
package: o.package.trim(),
|
|
50
|
+
version: o.version.trim(),
|
|
51
|
+
registeredAt: typeof o.registeredAt === 'string' ? o.registeredAt : '',
|
|
52
|
+
...(typeof o.heartbeat === 'string' && o.heartbeat.trim() ? { heartbeat: o.heartbeat.trim() } : {}),
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
return null // empty slot — bare core is valid
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Age (s) of the provider's heartbeat file, or null (none declared / unreadable). */
|
|
60
|
+
export function heartbeatAgeSecs(provider: MemoryProvider, nowMs: number = Date.now()): number | null {
|
|
61
|
+
if (!provider.heartbeat) return null
|
|
62
|
+
try {
|
|
63
|
+
return Math.max(0, Math.floor((nowMs - statSync(provider.heartbeat).mtimeMs) / 1000))
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface HostStatus {
|
|
70
|
+
version: string
|
|
71
|
+
daemon: { healthy: boolean; url: string | null; sock: string | null }
|
|
72
|
+
memory: { provider: MemoryProvider | null; heartbeatAgeSecs: number | null }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface HostStatusOptions {
|
|
76
|
+
env?: NodeJS.ProcessEnv
|
|
77
|
+
/** Injectable daemon probe (tests). Default: the real socket probe. */
|
|
78
|
+
probe?: () => Promise<boolean>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Assemble the host snapshot: baked version + daemon probe + memory slot. */
|
|
82
|
+
export async function hostStatus(opts: HostStatusOptions = {}): Promise<HostStatus> {
|
|
83
|
+
const env = opts.env ?? process.env
|
|
84
|
+
let url: string | null = null
|
|
85
|
+
let sock: string | null = null
|
|
86
|
+
try {
|
|
87
|
+
const d = JSON.parse(readFileSync(daemonDiscoveryPath({ env }), 'utf8')) as { tcp?: string | null; sock?: string | null }
|
|
88
|
+
url = d.tcp ?? null
|
|
89
|
+
sock = d.sock ?? null
|
|
90
|
+
} catch {
|
|
91
|
+
/* no discovery file → daemon down or never started; addresses stay null */
|
|
92
|
+
}
|
|
93
|
+
const health = await waitForDaemonHealthy({ env, timeoutMs: 2500, needConsecutive: 1, probe: opts.probe })
|
|
94
|
+
const provider = readMemoryProvider(env)
|
|
95
|
+
return {
|
|
96
|
+
version: IAPEER_VERSION,
|
|
97
|
+
daemon: { healthy: health.healthy, url, sock },
|
|
98
|
+
memory: { provider, heartbeatAgeSecs: provider ? heartbeatAgeSecs(provider) : null },
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Render the human status block (one fact per line; `memory:` per the slot contract). */
|
|
103
|
+
export function formatHostStatus(s: HostStatus): string {
|
|
104
|
+
const daemon = s.daemon.healthy
|
|
105
|
+
? `healthy${s.daemon.url ? ` @ ${s.daemon.url}` : ''}${s.daemon.sock ? ` + ${s.daemon.sock}` : ''}`
|
|
106
|
+
: 'NOT healthy (socket not accepting connections)'
|
|
107
|
+
// Heartbeat interpretation (slot contract, provider semantics agreed 10.06):
|
|
108
|
+
// file REMOVED on graceful shutdown → declared-but-absent = daemon not running;
|
|
109
|
+
// present = age shown. The core only REPORTS — staleness healing is the
|
|
110
|
+
// provider's own job (its verify/repair), never the core's.
|
|
111
|
+
let hb = ''
|
|
112
|
+
if (s.memory.provider?.heartbeat) {
|
|
113
|
+
hb = s.memory.heartbeatAgeSecs !== null
|
|
114
|
+
? ` (heartbeat ${s.memory.heartbeatAgeSecs}s ago)`
|
|
115
|
+
: ' (daemon not running — no heartbeat file)'
|
|
116
|
+
}
|
|
117
|
+
const memory = s.memory.provider ? `${s.memory.provider.provider} ${s.memory.provider.version}${hb}` : 'none'
|
|
118
|
+
return `iapeer ${s.version}\ndaemon: ${daemon}\nmemory: ${memory}\n`
|
|
119
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Memory slot — the DECLARATIVE provider slot (docs/Слот памяти — контракт
|
|
2
|
+
// memory provider.md): the core only READS the root declaration; absent /
|
|
3
|
+
// unreadable / invalid → EMPTY slot (bare core, valid state, never an error).
|
|
4
|
+
// Plus the host-status assembly + rendering (memory: <provider> | none).
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import {
|
|
11
|
+
formatHostStatus,
|
|
12
|
+
heartbeatAgeSecs,
|
|
13
|
+
hostStatus,
|
|
14
|
+
memoryProviderPath,
|
|
15
|
+
readMemoryProvider,
|
|
16
|
+
type HostStatus,
|
|
17
|
+
type MemoryProvider,
|
|
18
|
+
} from './index.ts'
|
|
19
|
+
|
|
20
|
+
const dirs: string[] = []
|
|
21
|
+
function mkTmp(): string {
|
|
22
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-slot-'))
|
|
23
|
+
dirs.push(d)
|
|
24
|
+
return d
|
|
25
|
+
}
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
function envFor(root: string): NodeJS.ProcessEnv {
|
|
31
|
+
return { IAPEER_ROOT: root } as NodeJS.ProcessEnv
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const VALID = {
|
|
35
|
+
provider: 'iapeer-memory',
|
|
36
|
+
package: '@agfpd/iapeer-memory',
|
|
37
|
+
version: '0.1.0',
|
|
38
|
+
registeredAt: '2026-06-10T00:00:00Z',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('readMemoryProvider (slot declaration, fail-open)', () => {
|
|
42
|
+
test('absent file → null (EMPTY slot — valid bare state)', () => {
|
|
43
|
+
expect(readMemoryProvider(envFor(mkTmp()))).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('valid declaration → parsed provider; heartbeat optional', () => {
|
|
47
|
+
const root = mkTmp()
|
|
48
|
+
writeFileSync(memoryProviderPath(envFor(root)), JSON.stringify({ ...VALID, heartbeat: '/tmp/hb' }))
|
|
49
|
+
const p = readMemoryProvider(envFor(root))
|
|
50
|
+
expect(p).toMatchObject({ ...VALID, heartbeat: '/tmp/hb' })
|
|
51
|
+
// without heartbeat — field absent, not empty-string
|
|
52
|
+
writeFileSync(memoryProviderPath(envFor(root)), JSON.stringify(VALID))
|
|
53
|
+
expect(readMemoryProvider(envFor(root))?.heartbeat).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('garbage / wrong shape / missing required fields → null, NEVER throws', () => {
|
|
57
|
+
const root = mkTmp()
|
|
58
|
+
const env = envFor(root)
|
|
59
|
+
for (const bad of ['NOT JSON {{{', '[]', '{}', JSON.stringify({ provider: 'x' }), JSON.stringify({ ...VALID, version: 42 })]) {
|
|
60
|
+
writeFileSync(memoryProviderPath(env), bad)
|
|
61
|
+
expect(readMemoryProvider(env)).toBeNull()
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('heartbeatAgeSecs', () => {
|
|
67
|
+
test('no heartbeat declared → null; declared but absent file → null; present → age', () => {
|
|
68
|
+
const root = mkTmp()
|
|
69
|
+
expect(heartbeatAgeSecs({ ...VALID } as MemoryProvider)).toBeNull()
|
|
70
|
+
const hb = join(root, 'memoryd.heartbeat')
|
|
71
|
+
expect(heartbeatAgeSecs({ ...VALID, heartbeat: hb } as MemoryProvider)).toBeNull() // not running
|
|
72
|
+
writeFileSync(hb, '')
|
|
73
|
+
const old = (Date.now() - 45_000) / 1000
|
|
74
|
+
utimesSync(hb, old, old)
|
|
75
|
+
const age = heartbeatAgeSecs({ ...VALID, heartbeat: hb } as MemoryProvider)
|
|
76
|
+
expect(age).toBeGreaterThanOrEqual(44)
|
|
77
|
+
expect(age).toBeLessThanOrEqual(60)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('hostStatus + formatHostStatus', () => {
|
|
82
|
+
test('empty slot → memory: none; daemon health from the injected probe', async () => {
|
|
83
|
+
const root = mkTmp()
|
|
84
|
+
const s = await hostStatus({ env: envFor(root), probe: async () => true })
|
|
85
|
+
expect(s.daemon.healthy).toBe(true)
|
|
86
|
+
expect(s.memory.provider).toBeNull()
|
|
87
|
+
expect(formatHostStatus(s)).toContain('memory: none')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('occupied slot + fresh heartbeat → provider line with age', async () => {
|
|
91
|
+
const root = mkTmp()
|
|
92
|
+
const env = envFor(root)
|
|
93
|
+
const hb = join(root, 'memoryd.heartbeat')
|
|
94
|
+
writeFileSync(hb, '')
|
|
95
|
+
writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, heartbeat: hb }))
|
|
96
|
+
const s = await hostStatus({ env, probe: async () => true })
|
|
97
|
+
expect(formatHostStatus(s)).toMatch(/memory: iapeer-memory 0\.1\.0 \(heartbeat \d+s ago\)/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('occupied slot, heartbeat declared but file ABSENT → "daemon not running" (graceful-shutdown semantics)', () => {
|
|
101
|
+
const s: HostStatus = {
|
|
102
|
+
version: '0.0.0',
|
|
103
|
+
daemon: { healthy: false, url: null, sock: null },
|
|
104
|
+
memory: {
|
|
105
|
+
provider: { ...VALID, heartbeat: '/nope/hb' } as MemoryProvider,
|
|
106
|
+
heartbeatAgeSecs: null,
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
const text = formatHostStatus(s)
|
|
110
|
+
expect(text).toContain('memory: iapeer-memory 0.1.0 (daemon not running — no heartbeat file)')
|
|
111
|
+
expect(text).toContain('daemon: NOT healthy')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('discovery file addresses surface in the daemon line', async () => {
|
|
115
|
+
const root = mkTmp()
|
|
116
|
+
const env = envFor(root)
|
|
117
|
+
const stateDir = join(root, 'state', 'iapeer')
|
|
118
|
+
mkdirSync(stateDir, { recursive: true })
|
|
119
|
+
writeFileSync(join(stateDir, 'router.json'), JSON.stringify({ sock: '/x/router.sock', tcp: 'http://127.0.0.1:8765/mcp' }))
|
|
120
|
+
const s = await hostStatus({ env, probe: async () => true })
|
|
121
|
+
const text = formatHostStatus(s)
|
|
122
|
+
expect(text).toContain('@ http://127.0.0.1:8765/mcp')
|
|
123
|
+
expect(text).toContain('+ /x/router.sock')
|
|
124
|
+
})
|
|
125
|
+
})
|