@agfpd/iapeer 0.2.7 → 0.2.8
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 +2 -2
- package/src/cli/cli.test.ts +1 -1
- package/src/core/constants.ts +9 -1
- package/src/daemon/daemon.test.ts +1 -1
- package/src/daemon/index.ts +1 -1
- package/src/launch/adapters/claude.ts +1 -1
- package/src/launch/composeSystemPrompt.layers.test.ts +93 -0
- package/src/launch/composeSystemPrompt.ts +84 -52
- package/src/launch/types.ts +22 -8
- package/src/transport/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Foundation core for the
|
|
3
|
+
"version": "0.2.8",
|
|
4
|
+
"description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"iapeer": "bin/iapeer"
|
package/src/cli/cli.test.ts
CHANGED
|
@@ -128,7 +128,7 @@ describe('send validation', () => {
|
|
|
128
128
|
await register('alpha')
|
|
129
129
|
await expect(
|
|
130
130
|
sendMessage({ from: 'claude-alpha', target: 'nobody', message: 'hi', env: env() }),
|
|
131
|
-
).rejects.toThrow(/not in the
|
|
131
|
+
).rejects.toThrow(/not in the iapeer peers index|self/)
|
|
132
132
|
})
|
|
133
133
|
})
|
|
134
134
|
|
package/src/core/constants.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Canonical constants for the
|
|
1
|
+
// Canonical constants for the iapeer foundation.
|
|
2
2
|
// Consolidated from inter-agent-protocol/src/lib/constants.ts (wins as-is) and
|
|
3
3
|
// extended with storage-layer path names (blueprint §1 core/constants).
|
|
4
4
|
|
|
@@ -131,6 +131,14 @@ export function resolveSockDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
131
131
|
// === per-peer cwd scope ===
|
|
132
132
|
export const IAPEER_DIR = '.iapeer'
|
|
133
133
|
export const PEER_PROFILE_FILE = 'peer-profile.json'
|
|
134
|
+
// Doctrine-fragments layer (Канал A, слой 5): a primitive-owned `.iapeer/fragments/`
|
|
135
|
+
// subdir under BOTH the global root (`~/.iapeer/fragments/`, host-wide) and the
|
|
136
|
+
// per-peer cwd (`<cwd>/.iapeer/fragments/`, per-peer). Holds machine-regenerated
|
|
137
|
+
// `*.md` fragments that an ecosystem primitive writes + rotates itself (e.g.
|
|
138
|
+
// iapeer-memory's guide + per-peer note index) — merged into the system prompt so
|
|
139
|
+
// the context survives compaction, yet kept OUT of the hand-authored IAPEER.md
|
|
140
|
+
// doctrine (two writers, mutual-rollback). See composeSystemPrompt Layer 5.
|
|
141
|
+
export const FRAGMENTS_DIR = 'fragments'
|
|
134
142
|
|
|
135
143
|
// === global scope ~/.iapeer/ ===
|
|
136
144
|
export const IAP_PLUGIN_DIR = 'iap'
|
|
@@ -93,7 +93,7 @@ describe('callTool (no wake passed → Ф1 offline behaviour, never spawns)', ()
|
|
|
93
93
|
const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
|
|
94
94
|
const r = await callTool(caller, 'send_to_peer', { personality: 'nobody', message: 'hi' })
|
|
95
95
|
expect(r.isError).toBe(true)
|
|
96
|
-
expect(r.content[0].text).toMatch(/not in the
|
|
96
|
+
expect(r.content[0].text).toMatch(/not in the iapeer peers index/)
|
|
97
97
|
})
|
|
98
98
|
|
|
99
99
|
test('unknown tool → error', async () => {
|
package/src/daemon/index.ts
CHANGED
|
@@ -77,7 +77,7 @@ export function resolveCallerFromHeader(value: string | undefined, index: PeersI
|
|
|
77
77
|
|
|
78
78
|
export function buildSendDescription(index: PeersIndex): string {
|
|
79
79
|
const lines = [
|
|
80
|
-
'Send a message to a known
|
|
80
|
+
'Send a message to a known iapeer peer through Inter-Agent-Protocol.',
|
|
81
81
|
'Peers can be agents, humans, or services. Runtime is the delivery surface, not the peer type.',
|
|
82
82
|
'Current delivery supports any runtime endpoint that follows the IAP tmux socket convention. Claude/Codex are built-in local runtimes; external runtimes such as telegram can be exposed by runtime-router packages.',
|
|
83
83
|
'',
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// (the dev-channels "I am using this for local development" Enter, 335-345).
|
|
11
11
|
// - Spawned-Peer/src/spawner.ts — buildClaudeArgv (759-787: same flags +
|
|
12
12
|
// optional --resume <uuid>) and findLatestTranscript (522-538: resume uuid).
|
|
13
|
-
// -
|
|
13
|
+
// - iapeer/src/lifecycle/index.ts — claudeInputReady / claudeBootDialog /
|
|
14
14
|
// newestClaudeTranscriptMtime / findLatestClaudeTranscript. This is the
|
|
15
15
|
// canonical port: the slug is realpath(cwd).replace(/[^a-zA-Z0-9]/g,'-') —
|
|
16
16
|
// claude encodes EVERY non-alphanumeric char (not just '/'); the old
|
|
@@ -130,6 +130,65 @@ describe('composeSystemPrompt — layer 4 (other domains)', () => {
|
|
|
130
130
|
})
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
+
describe('composeSystemPrompt — layer 5 (doctrine fragments)', () => {
|
|
134
|
+
const frag = (over: Partial<PromptDomainBlock>): PromptDomainBlock => ({ domain: 'F', ...over })
|
|
135
|
+
|
|
136
|
+
test('empty/omitted fragments add NOTHING (legacy bytes preserved)', () => {
|
|
137
|
+
const legacy = composeSystemPrompt(base)
|
|
138
|
+
expect(composeSystemPrompt({ ...base, promptFragments: [] })).toBe(legacy)
|
|
139
|
+
expect(composeSystemPrompt({ ...base, promptFragments: undefined })).toBe(legacy)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('a presence-marker fragment (empty halves) emits no text and no dangling separator', () => {
|
|
143
|
+
const legacy = composeSystemPrompt(base)
|
|
144
|
+
expect(composeSystemPrompt({ ...base, promptFragments: [frag({ local: '' })] })).toBe(legacy)
|
|
145
|
+
expect(composeSystemPrompt({ ...base, promptFragments: [frag({ global: '', local: '' })] })).toBe(legacy)
|
|
146
|
+
const out = composeSystemPrompt({
|
|
147
|
+
...base,
|
|
148
|
+
promptFragments: [frag({ domain: 'EMPTY', local: '' }), frag({ domain: 'REAL', local: 'idx-content\n' })],
|
|
149
|
+
})
|
|
150
|
+
expect(out).toContain('idx-content')
|
|
151
|
+
expect(out).not.toMatch(/\n{4,}/)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('within a fragment stem, global (host-wide guide) precedes local (per-peer index)', () => {
|
|
155
|
+
const out = composeSystemPrompt({
|
|
156
|
+
...base,
|
|
157
|
+
promptFragments: [frag({ domain: 'MEM', global: 'mem-guide', local: 'mem-index' })],
|
|
158
|
+
})
|
|
159
|
+
expect(out).toContain('mem-guide\nmem-index')
|
|
160
|
+
expect(out.indexOf('mem-guide')).toBeLessThan(out.indexOf('mem-index'))
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('layer 5 sits AFTER layer 4 (most-volatile last), separated by a blank line', () => {
|
|
164
|
+
const out = composeSystemPrompt({
|
|
165
|
+
...base,
|
|
166
|
+
pluginDomains: [{ domain: 'D', global: 'domain-text' }],
|
|
167
|
+
promptFragments: [{ domain: 'MEM', local: 'fragment-text' }],
|
|
168
|
+
})
|
|
169
|
+
expect(out.indexOf('domain-text')).toBeLessThan(out.indexOf('fragment-text'))
|
|
170
|
+
expect(out).toContain('domain-text\n\nfragment-text')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('full 5-layer ordering: YAML → doctrine → registry → domains → fragments', () => {
|
|
174
|
+
const out = composeSystemPrompt({
|
|
175
|
+
...base,
|
|
176
|
+
peers: [peerA],
|
|
177
|
+
pluginDomains: [{ domain: 'D', global: 'domain-text' }],
|
|
178
|
+
promptFragments: [{ domain: 'MEM', global: 'fragment-text' }],
|
|
179
|
+
})
|
|
180
|
+
const order = [
|
|
181
|
+
out.indexOf('personality:'),
|
|
182
|
+
out.indexOf('Be the implementer.'),
|
|
183
|
+
out.indexOf('## Known peers'),
|
|
184
|
+
out.indexOf('domain-text'),
|
|
185
|
+
out.indexOf('fragment-text'),
|
|
186
|
+
]
|
|
187
|
+
expect(order.every(i => i >= 0)).toBe(true)
|
|
188
|
+
expect(order).toEqual([...order].sort((a, b) => a - b))
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
133
192
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
193
|
// gatherPromptInput — FS discovery over ~/.iapeer + <cwd>/.iapeer
|
|
135
194
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -161,6 +220,17 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
|
|
|
161
220
|
// a non-.md file must be ignored
|
|
162
221
|
writeFileSync(join(localRoot, 'peer-profile.json'), '{}')
|
|
163
222
|
|
|
223
|
+
// Layer 5 — fragments/ subdir of both roots: a paired stem (host-wide guide +
|
|
224
|
+
// per-peer index) and a global-only fragment; a non-.md file must be ignored.
|
|
225
|
+
const globalFrags = join(globalRoot, 'fragments')
|
|
226
|
+
const localFrags = join(localRoot, 'fragments')
|
|
227
|
+
mkdirSync(globalFrags, { recursive: true })
|
|
228
|
+
mkdirSync(localFrags, { recursive: true })
|
|
229
|
+
writeFileSync(join(globalFrags, 'iapeer-memory.md'), 'mem-guide\n') // host-wide guide
|
|
230
|
+
writeFileSync(join(localFrags, 'iapeer-memory.md'), 'mem-index\n') // per-peer index
|
|
231
|
+
writeFileSync(join(globalFrags, 'other-primitive.md'), 'other-frag\n')
|
|
232
|
+
writeFileSync(join(localFrags, 'notes.txt'), 'ignored') // non-.md, must be ignored
|
|
233
|
+
|
|
164
234
|
// Layer 3 — registry at the global root (deliberately UNSORTED on disk)
|
|
165
235
|
const index = {
|
|
166
236
|
version: 2,
|
|
@@ -207,6 +277,23 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
|
|
|
207
277
|
expect(byName.MARKER).toEqual({ domain: 'MARKER', local: '' }) // present but empty
|
|
208
278
|
})
|
|
209
279
|
|
|
280
|
+
test('layer 5: fragments/ subdir scanned, paired by stem (guide global + index local), sorted', () => {
|
|
281
|
+
const input = gather()
|
|
282
|
+
const frags = input.promptFragments ?? []
|
|
283
|
+
expect(frags.map(f => f.domain)).toEqual(['iapeer-memory', 'other-primitive'])
|
|
284
|
+
const byName = Object.fromEntries(frags.map(f => [f.domain, f]))
|
|
285
|
+
expect(byName['iapeer-memory']).toEqual({ domain: 'iapeer-memory', global: 'mem-guide\n', local: 'mem-index\n' })
|
|
286
|
+
expect(byName['other-primitive']).toEqual({ domain: 'other-primitive', global: 'other-frag\n' })
|
|
287
|
+
// the non-.md notes.txt never became a fragment
|
|
288
|
+
expect(frags.map(f => f.domain)).not.toContain('notes')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('layer 5: fragments are NOT confused with the root-level Layer-4 domains', () => {
|
|
292
|
+
const input = gather()
|
|
293
|
+
// the fragments dir itself is a directory, not a `.md` file → never a Layer-4 domain
|
|
294
|
+
expect((input.pluginDomains ?? []).map(d => d.domain)).not.toContain('fragments')
|
|
295
|
+
})
|
|
296
|
+
|
|
210
297
|
test('layer 3: registry projected to EXACTLY 5 fields, sorted by personality', () => {
|
|
211
298
|
const input = gather()
|
|
212
299
|
expect((input.peers ?? []).map(p => p.personality)).toEqual(['arthur', 'boris'])
|
|
@@ -237,14 +324,19 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
|
|
|
237
324
|
expect(prompt).toContain('aaa-global-only')
|
|
238
325
|
expect(prompt).toContain('mm-global\nmm-local')
|
|
239
326
|
expect(prompt).toContain('zzz-local-only')
|
|
327
|
+
// Layer 5 (fragments: guide+index paired, global-only fragment)
|
|
328
|
+
expect(prompt).toContain('mem-guide\nmem-index')
|
|
329
|
+
expect(prompt).toContain('other-frag')
|
|
240
330
|
|
|
241
331
|
const order = [
|
|
242
332
|
prompt.indexOf('personality: "iapeer"'),
|
|
243
333
|
prompt.indexOf('LOCAL ROLE'),
|
|
244
334
|
prompt.indexOf('## Known peers'),
|
|
245
335
|
prompt.indexOf('aaa-global-only'),
|
|
336
|
+
prompt.indexOf('mem-guide'), // Layer 5 — after every Layer-4 domain
|
|
246
337
|
]
|
|
247
338
|
expect(order).toEqual([...order].sort((a, b) => a - b))
|
|
339
|
+
expect(prompt.indexOf('zzz-local-only')).toBeLessThan(prompt.indexOf('mem-guide'))
|
|
248
340
|
// marker file produced no content and no blank-block artifact
|
|
249
341
|
expect(prompt).not.toContain('MARKER')
|
|
250
342
|
expect(prompt).not.toMatch(/\n{4,}/)
|
|
@@ -263,6 +355,7 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
|
|
|
263
355
|
expect(input.peerDoctrine).toBe('')
|
|
264
356
|
expect(input.globalDoctrine).toBeUndefined()
|
|
265
357
|
expect(input.pluginDomains).toEqual([])
|
|
358
|
+
expect(input.promptFragments).toEqual([]) // no fragments/ subdir → empty layer
|
|
266
359
|
expect(input.peers).toEqual([])
|
|
267
360
|
// composes to just the YAML block (+ empty doctrine), no extra layers
|
|
268
361
|
const prompt = composeSystemPrompt(input)
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { readdirSync, readFileSync, statSync } from 'fs'
|
|
12
12
|
import { join } from 'path'
|
|
13
|
-
import { IAPEER_DIR } from '../core/constants.ts'
|
|
13
|
+
import { FRAGMENTS_DIR, IAPEER_DIR } from '../core/constants.ts'
|
|
14
14
|
import { publicPeerSummary, readPeersIndex, type PublicPeerSummary } from '../registry/index.ts'
|
|
15
15
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
16
16
|
import type { ComposePromptInput, PromptDomainBlock } from './types.ts'
|
|
@@ -93,11 +93,14 @@ export function composeSystemPrompt(input: ComposePromptInput): string {
|
|
|
93
93
|
// jq output — the golden test pins this exactly.
|
|
94
94
|
const layer12 = yaml + global + input.peerDoctrine
|
|
95
95
|
|
|
96
|
-
// Layer 3 (registry)
|
|
97
|
-
// have content.
|
|
96
|
+
// Layer 3 (registry), Layer 4 (operator/plugin domains) and Layer 5 (primitive
|
|
97
|
+
// doctrine fragments) are appended ONLY when they have content. Layer 5 sits LAST
|
|
98
|
+
// — it is the most volatile (machine-regenerated on every source change), so it
|
|
99
|
+
// never disturbs the stable prefix (YAML + doctrine + registry + domains) above it.
|
|
98
100
|
const layer3 = renderRegistry(input.peers ?? [])
|
|
99
|
-
const layer4 =
|
|
100
|
-
const
|
|
101
|
+
const layer4 = renderMergedBlocks(input.pluginDomains ?? [])
|
|
102
|
+
const layer5 = renderMergedBlocks(input.promptFragments ?? [])
|
|
103
|
+
const sections = [layer12, layer3, layer4, layer5].filter(section => section.length > 0)
|
|
101
104
|
|
|
102
105
|
// ONE section (no registry, no extra domains) → the legacy bytes, UNTOUCHED —
|
|
103
106
|
// the golden test pins layer12 exactly (peerDoctrine verbatim, incl. its own
|
|
@@ -129,36 +132,42 @@ function renderRegistry(peers: readonly PublicPeerSummary[]): string {
|
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
135
|
+
// Layers 4 & 5 — merged global/local blocks. Shared renderer:
|
|
136
|
+
// • Layer 4: every non-IAPEER `<DOMAIN>.md` pair at the .iapeer/ root (operator /
|
|
137
|
+
// plugin user-settings; SPAWNER_INSTRUCTIONS.md, … flow in organically).
|
|
138
|
+
// • Layer 5: every `<STEM>.md` pair in .iapeer/fragments/ (primitive-owned,
|
|
139
|
+
// machine-regenerated doctrine fragments — iapeer-memory's guide + note index).
|
|
140
|
+
// Both merge general → specific (global then local). The merge is ORGANIC: stems
|
|
141
|
+
// are used only for stable ordering (done by the gatherer), never emitted. An empty
|
|
142
|
+
// file is a presence marker (Канал B) and contributes no text — so a fragment dir
|
|
143
|
+
// that drops to a 0-byte placeholder, or whose file vanishes mid-rotation, yields
|
|
144
|
+
// nothing here rather than a dangling separator.
|
|
137
145
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
138
146
|
|
|
139
|
-
function
|
|
140
|
-
const
|
|
141
|
-
for (const
|
|
142
|
-
// Per
|
|
147
|
+
function renderMergedBlocks(blocks: readonly PromptDomainBlock[]): string {
|
|
148
|
+
const out: string[] = []
|
|
149
|
+
for (const b of blocks) {
|
|
150
|
+
// Per stem: global then local (general → specific), each trimmed of its
|
|
143
151
|
// trailing newlines so a 0-byte marker (or a newline-only file) drops out and
|
|
144
|
-
// the spacing is uniform. Halves of one
|
|
145
|
-
// distinct
|
|
146
|
-
const halves = [
|
|
152
|
+
// the spacing is uniform. Halves of one stem are kept tight (single '\n');
|
|
153
|
+
// distinct stems are separated by a blank line below.
|
|
154
|
+
const halves = [b.global, b.local]
|
|
147
155
|
.filter((s): s is string => s !== undefined)
|
|
148
156
|
.map(s => s.replace(/\n+$/, ''))
|
|
149
157
|
.filter(s => s.length > 0)
|
|
150
|
-
if (halves.length > 0)
|
|
158
|
+
if (halves.length > 0) out.push(halves.join('\n'))
|
|
151
159
|
}
|
|
152
|
-
return
|
|
160
|
+
return out.join('\n\n')
|
|
153
161
|
}
|
|
154
162
|
|
|
155
163
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
156
164
|
// gatherPromptInput — the FS-discovery half: scan ~/.iapeer/*.md (global) and
|
|
157
165
|
// <cwd>/.iapeer/*.md (local), split IAPEER.md (Layer 2) from every other domain
|
|
158
|
-
// (Layer 4),
|
|
159
|
-
// into a ready-to-render
|
|
160
|
-
//
|
|
161
|
-
// This is the ONLY FS-touching
|
|
166
|
+
// (Layer 4), scan the fragments/ subdir of both roots (Layer 5), and project the
|
|
167
|
+
// live registry through publicPeerSummary (Layer 3), into a ready-to-render
|
|
168
|
+
// ComposePromptInput. Layer-1 identity/host facts are supplied by the caller
|
|
169
|
+
// (lifecycle gathers them via sw_vers/hostname/etc). This is the ONLY FS-touching
|
|
170
|
+
// part; composeSystemPrompt itself stays pure.
|
|
162
171
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
163
172
|
|
|
164
173
|
export interface GatherPromptOptions {
|
|
@@ -187,28 +196,51 @@ function readFileIfPresent(path: string): string | undefined {
|
|
|
187
196
|
}
|
|
188
197
|
}
|
|
189
198
|
|
|
190
|
-
/** `*.md` files directly
|
|
191
|
-
*
|
|
192
|
-
|
|
193
|
-
* read (join(root,'IAPEER.md')) resolves a lowercase `iapeer.md` on a case-
|
|
194
|
-
* insensitive FS (macOS APFS), so a byte-exact exclusion here would let that same
|
|
195
|
-
* file ALSO surface as a Layer-4 domain and duplicate the doctrine. Lowercasing
|
|
196
|
-
* both also picks up an uppercase-extension `NOTES.MD` (spec: no root MD ignored). */
|
|
197
|
-
function listDomainFiles(root: string): string[] {
|
|
198
|
-
const doctrineLower = IAPEER_DOCTRINE_FILE.toLowerCase()
|
|
199
|
+
/** `*.md` files directly in `dir` (NOT recursing). Missing dir → []. The `.md`
|
|
200
|
+
* test is CASE-INSENSITIVE so an uppercase-extension `NOTES.MD` is picked up too. */
|
|
201
|
+
function listMdFiles(dir: string): string[] {
|
|
199
202
|
try {
|
|
200
|
-
return readdirSync(
|
|
201
|
-
.filter(e =>
|
|
202
|
-
if (!e.isFile()) return false
|
|
203
|
-
const lower = e.name.toLowerCase()
|
|
204
|
-
return lower.endsWith('.md') && lower !== doctrineLower
|
|
205
|
-
})
|
|
203
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
204
|
+
.filter(e => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
206
205
|
.map(e => e.name)
|
|
207
206
|
} catch {
|
|
208
207
|
return []
|
|
209
208
|
}
|
|
210
209
|
}
|
|
211
210
|
|
|
211
|
+
/** Layer-4 domain candidates: `*.md` directly at a `.iapeer` root, excluding
|
|
212
|
+
* IAPEER.md (Layer 2). The doctrine exclusion is CASE-INSENSITIVE: the Layer-2
|
|
213
|
+
* read (join(root,'IAPEER.md')) resolves a lowercase `iapeer.md` on a case-
|
|
214
|
+
* insensitive FS (macOS APFS), so a byte-exact exclusion here would let that same
|
|
215
|
+
* file ALSO surface as a Layer-4 domain and duplicate the doctrine. */
|
|
216
|
+
function listDomainFiles(root: string): string[] {
|
|
217
|
+
const doctrineLower = IAPEER_DOCTRINE_FILE.toLowerCase()
|
|
218
|
+
return listMdFiles(root).filter(name => name.toLowerCase() !== doctrineLower)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Build a list of merged global/local blocks (Layer 4 OR Layer 5) from the
|
|
222
|
+
* union of `*.md` stems present in the global and/or local dir, sorted by
|
|
223
|
+
* filename (plain codepoint order) for a deterministic prompt. `lister` selects
|
|
224
|
+
* the candidate `.md` files — Layer 4 excludes the IAPEER.md doctrine, Layer 5
|
|
225
|
+
* takes every fragment. A read that races a rotation (file removed between listdir
|
|
226
|
+
* and read) → that half is undefined and drops out; a 0-byte file → '' and is
|
|
227
|
+
* dropped by the renderer. */
|
|
228
|
+
function gatherMergedBlocks(
|
|
229
|
+
globalDir: string,
|
|
230
|
+
localDir: string,
|
|
231
|
+
lister: (dir: string) => string[] = listMdFiles,
|
|
232
|
+
): PromptDomainBlock[] {
|
|
233
|
+
const names = new Set<string>([...lister(globalDir), ...lister(localDir)])
|
|
234
|
+
return [...names].sort().map(name => {
|
|
235
|
+
const block: PromptDomainBlock = { domain: name.slice(0, -3) } // strip ".md"
|
|
236
|
+
const global = readFileIfPresent(join(globalDir, name))
|
|
237
|
+
const local = readFileIfPresent(join(localDir, name))
|
|
238
|
+
if (global !== undefined) block.global = global
|
|
239
|
+
if (local !== undefined) block.local = local
|
|
240
|
+
return block
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
212
244
|
export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput {
|
|
213
245
|
const env = opts.env ?? process.env
|
|
214
246
|
const globalRoot = opts.globalRoot ?? resolveGlobalRoot(env)
|
|
@@ -220,20 +252,19 @@ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput
|
|
|
220
252
|
const peerDoctrine = readFileIfPresent(join(localRoot, IAPEER_DOCTRINE_FILE)) ?? ''
|
|
221
253
|
|
|
222
254
|
// Layer 4 — union of every non-IAPEER domain present globally and/or locally,
|
|
223
|
-
// sorted by filename for a deterministic prompt (
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
})
|
|
255
|
+
// sorted by filename for a deterministic prompt. Empty (0-byte) files stay as
|
|
256
|
+
// presence markers — readFileIfPresent returns '', and the renderer drops empty
|
|
257
|
+
// halves, so a marker contributes no text/separator.
|
|
258
|
+
const pluginDomains = gatherMergedBlocks(globalRoot, localRoot, listDomainFiles)
|
|
259
|
+
|
|
260
|
+
// Layer 5 — doctrine fragments: every `*.md` in the fragments/ subdir of both
|
|
261
|
+
// roots, merged per stem (global guide → per-peer specifics). Primitive-owned and
|
|
262
|
+
// machine-regenerated; an atomic rotation that briefly removes/replaces a file is
|
|
263
|
+
// tolerated (a racing read just drops that half — no partial bytes, no throw).
|
|
264
|
+
const promptFragments = gatherMergedBlocks(
|
|
265
|
+
join(globalRoot, FRAGMENTS_DIR),
|
|
266
|
+
join(localRoot, FRAGMENTS_DIR),
|
|
267
|
+
)
|
|
237
268
|
|
|
238
269
|
// Layer 3 — peers projected through the shared normalizer, sorted by personality
|
|
239
270
|
// (determinism even if the on-disk file was hand-edited). `opts.peers` lets a
|
|
@@ -257,5 +288,6 @@ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput
|
|
|
257
288
|
...(globalDoctrine !== undefined ? { globalDoctrine } : {}),
|
|
258
289
|
peers,
|
|
259
290
|
pluginDomains,
|
|
291
|
+
promptFragments,
|
|
260
292
|
}
|
|
261
293
|
}
|
package/src/launch/types.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type { PublicPeerSummary } from '../registry/index.ts'
|
|
|
20
20
|
|
|
21
21
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
22
|
// composeSystemPrompt — the layered Канал-A merge (docs/Сборка системного
|
|
23
|
-
// промпта — слои и каналы.md).
|
|
23
|
+
// промпта — слои и каналы.md). Five layers, general → specific (local overrides
|
|
24
24
|
// global):
|
|
25
25
|
// 1. System YAML (identity + host facts) — jq-GOLDEN, byte-for-byte.
|
|
26
26
|
// 2. iapeer doctrine: ~/.iapeer/IAPEER.md (global) + <cwd>/.iapeer/IAPEER.md (local).
|
|
@@ -28,19 +28,27 @@ export type { PublicPeerSummary } from '../registry/index.ts'
|
|
|
28
28
|
// 4. Plugin user-settings: every OTHER <DOMAIN>.md at the .iapeer/ root, global
|
|
29
29
|
// + local merged per domain. Custom files (SPAWNER_INSTRUCTIONS.md, …) flow
|
|
30
30
|
// in organically here — no special-casing.
|
|
31
|
+
// 5. Doctrine fragments: every `*.md` in the `.iapeer/fragments/` subdir, global
|
|
32
|
+
// (~/.iapeer/fragments/) + local (<cwd>/.iapeer/fragments/) merged per stem.
|
|
33
|
+
// PRIMITIVE-owned, machine-regenerated (iapeer-memory's guide + note index, …)
|
|
34
|
+
// — a dedicated namespace kept OUT of the hand-authored IAPEER.md doctrine so
|
|
35
|
+
// the auto-writer and the human writer never share a file. Sits LAST: the most
|
|
36
|
+
// volatile layer, so edits to it never disturb the stable prefix above.
|
|
31
37
|
// composeSystemPrompt is a PURE renderer over already-gathered data; the FS
|
|
32
38
|
// discovery lives in gatherPromptInput (mirrors the bash split: shell read the
|
|
33
39
|
// files, the renderer just laid out bytes).
|
|
34
40
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
41
|
|
|
36
|
-
/** One
|
|
37
|
-
*
|
|
38
|
-
*
|
|
42
|
+
/** One merged block: the global + local halves of a single stem (either may be
|
|
43
|
+
* absent), used for BOTH Layer 4 (`<DOMAIN>.md` at the .iapeer/ root) and Layer 5
|
|
44
|
+
* (`<STEM>.md` in .iapeer/fragments/). `domain` is the filename stem, used only
|
|
45
|
+
* for stable ordering — it is NOT emitted (the merge is organic). Within a block
|
|
46
|
+
* global precedes local (general → specific). */
|
|
39
47
|
export interface PromptDomainBlock {
|
|
40
48
|
domain: string
|
|
41
|
-
/** ~/.iapeer/<DOMAIN>.md
|
|
49
|
+
/** Global half content (general): ~/.iapeer/<DOMAIN>.md or ~/.iapeer/fragments/<STEM>.md. */
|
|
42
50
|
global?: string
|
|
43
|
-
/**
|
|
51
|
+
/** Local half content (specific — overrides global): <cwd>/.iapeer/… counterpart. */
|
|
44
52
|
local?: string
|
|
45
53
|
}
|
|
46
54
|
|
|
@@ -66,6 +74,10 @@ export interface ComposePromptInput {
|
|
|
66
74
|
/** Layer 4: every non-IAPEER `<DOMAIN>.md` pair at the .iapeer/ root. Empty/
|
|
67
75
|
* omitted → the layer emits nothing. */
|
|
68
76
|
pluginDomains?: PromptDomainBlock[]
|
|
77
|
+
/** Layer 5: every `<STEM>.md` pair in the .iapeer/fragments/ subdir (global +
|
|
78
|
+
* local), primitive-owned and machine-regenerated. Empty/omitted → the layer
|
|
79
|
+
* emits nothing. Appended LAST (after Layer 4). */
|
|
80
|
+
promptFragments?: PromptDomainBlock[]
|
|
69
81
|
}
|
|
70
82
|
|
|
71
83
|
/**
|
|
@@ -87,10 +99,12 @@ export interface ComposePromptInput {
|
|
|
87
99
|
* peerDoctrine
|
|
88
100
|
* [\n\n + registry section — only when peers.length > 0]
|
|
89
101
|
* [\n\n + merged domains — only when pluginDomains is non-empty]
|
|
102
|
+
* [\n\n + merged fragments — only when promptFragments is non-empty]
|
|
90
103
|
*
|
|
91
|
-
* Layers 1+2 are byte-for-byte the legacy jq output; layers 3+4 are appended
|
|
104
|
+
* Layers 1+2 are byte-for-byte the legacy jq output; layers 3+4+5 are appended
|
|
92
105
|
* (each as a `\n\n`-separated section) ONLY when they have content, so a peer
|
|
93
|
-
* with no registry
|
|
106
|
+
* with no registry, no extra domains and no fragments produces the exact legacy
|
|
107
|
+
* bytes.
|
|
94
108
|
*
|
|
95
109
|
* Each YAML value is a JSON string literal (jq @json: JSON.stringify), which is
|
|
96
110
|
* also a valid YAML double-quoted scalar — safe against colons/quotes/newlines.
|
package/src/transport/index.ts
CHANGED
|
@@ -469,7 +469,7 @@ export async function routeSend(
|
|
|
469
469
|
const index = readPeersIndex()
|
|
470
470
|
const peer = findPeer(index, personality)
|
|
471
471
|
if (!peer) {
|
|
472
|
-
return err(`peer "${personality}" is not in the
|
|
472
|
+
return err(`peer "${personality}" is not in the iapeer peers index; message NOT delivered`)
|
|
473
473
|
}
|
|
474
474
|
|
|
475
475
|
// Built once — it is both the live-delivery payload and, on a miss, the wake
|