@agfpd/iapeer 0.2.11 → 0.2.12

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.12",
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
@@ -406,8 +407,30 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
406
407
  } else if (infra.length) {
407
408
  out(`onboard --dry-run: would onboard infra runtimes: ${infra.join(', ')}\n`)
408
409
  }
410
+ // Memory slot (контракт «Слот памяти», 10.06): optional DEFAULT-YES install of
411
+ // the default provider; its own init runs with INHERITED stdio (the provider
412
+ // owns the install questions). The step is REPORT-ONLY for the exit code — an
413
+ // empty slot is a valid state regardless of why.
414
+ const { onboardMemoryProvider } = await import('../onboard/memory.ts')
415
+ const mem = await onboardMemoryProvider({
416
+ skip: flags['no-memory'] === true,
417
+ package: typeof flags.memory === 'string' ? flags.memory : undefined,
418
+ dryRun: flags['dry-run'] === true,
419
+ env,
420
+ })
421
+ const memLabel = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
422
+ out(`memory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${memLabel})\n`)
409
423
  return r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
410
424
  }
425
+ case 'status': {
426
+ // Host snapshot (контракт «Слот памяти» §status): version + daemon health +
427
+ // the memory-slot line. Exit 1 iff the daemon is unhealthy (usable as a
428
+ // health gate); an EMPTY memory slot is a valid state and never fails.
429
+ const { hostStatus, formatHostStatus } = await import('../status/index.ts')
430
+ const s = await hostStatus({ env })
431
+ out(formatHostStatus(s))
432
+ return s.daemon.healthy ? 0 : 1
433
+ }
411
434
  case 'install-runtime': {
412
435
  // §6 onboard a runtime END-TO-END: npx-install the package (auto-resolved from
413
436
  // the built-in runtime→package registry, or --package; self-deploys bin +
package/src/index.ts CHANGED
@@ -25,5 +25,8 @@ 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'
28
31
  export { composeSystemPrompt, gatherPromptInput } from './launch/composeSystemPrompt.ts'
29
32
  export type { GatherPromptOptions } from './launch/composeSystemPrompt.ts'
@@ -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
+ }
@@ -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
+ })