Version not found. Please check the version and try again.

@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
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>] register the agfpd marketplace (+ npx-install & deploy infra runtimes)
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
+ }
@@ -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
+ })