a2acalling 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -983,12 +983,21 @@ https://github.com/onthegonow/a2a_calling`;
983
983
  }
984
984
  }
985
985
 
986
- async function externalPingCheck(targetUrl) {
987
- const providers = [
988
- {
989
- name: 'allorigins',
990
- buildUrl: () => {
991
- const u = new URL('https://api.allorigins.win/raw');
986
+ async function externalPingCheck(targetUrl) {
987
+ // Try direct access first. In practice this is the most reliable signal:
988
+ // it avoids flaky third-party proxies and catches obvious scheme/port mistakes.
989
+ try {
990
+ const direct = await fetchUrlText(targetUrl, 2500);
991
+ return { ok: looksLikePong(direct.body), provider: 'direct', statusCode: direct.statusCode };
992
+ } catch (err) {
993
+ // Fall back to remote fetch providers below.
994
+ }
995
+
996
+ const providers = [
997
+ {
998
+ name: 'allorigins',
999
+ buildUrl: () => {
1000
+ const u = new URL('https://api.allorigins.win/raw');
992
1001
  u.searchParams.set('url', targetUrl);
993
1002
  return u.toString();
994
1003
  }
@@ -1109,13 +1118,15 @@ https://github.com/onthegonow/a2a_calling`;
1109
1118
  contextFiles = disc.readContextFiles(workspaceDir);
1110
1119
  const forceManifest = Boolean(args.flags.force || args.flags['regen-manifest'] || args.flags.regenManifest);
1111
1120
  if (forceManifest) {
1112
- const generated = disc.generateDefaultManifest(contextFiles);
1121
+ // Force-regen uses minimal starter; agent-driven extraction is the
1122
+ // proper way to populate topics (via `a2a onboard --submit`).
1123
+ const generated = disc.generateDefaultManifest();
1113
1124
  disc.saveManifest(generated);
1114
1125
  manifest = generated;
1115
1126
  } else {
1116
1127
  manifest = disc.loadManifest();
1117
1128
  if (!manifest || Object.keys(manifest).length === 0) {
1118
- const generated = disc.generateDefaultManifest(contextFiles);
1129
+ const generated = disc.generateDefaultManifest();
1119
1130
  disc.saveManifest(generated);
1120
1131
  manifest = generated;
1121
1132
  }
@@ -1352,22 +1363,25 @@ https://github.com/onthegonow/a2a_calling`;
1352
1363
  }
1353
1364
  }
1354
1365
 
1355
- if (inviteLooksLocal) {
1356
- console.log('Skipping external reachability check: invite host looks local/unroutable.');
1357
- } else {
1358
- const extPing = await externalPingCheck(expectedPingUrl);
1359
- if (extPing.ok) {
1360
- console.log(`✅ External ping OK (${extPing.provider})`);
1361
- } else if (!args.flags['skip-verify']) {
1366
+ if (inviteLooksLocal) {
1367
+ console.log('Skipping external reachability check: invite host looks local/unroutable.');
1368
+ } else {
1369
+ const extPing = await externalPingCheck(expectedPingUrl);
1370
+ if (extPing.ok) {
1371
+ console.log(`✅ External ping OK (${extPing.provider})`);
1372
+ } else if (args.flags['skip-verify']) {
1373
+ console.log('⚠️ External ping FAILED (skipped via --skip-verify).');
1374
+ } else if (args.flags['confirm-ingress']) {
1375
+ console.log('⚠️ External ping FAILED (continuing due to --confirm-ingress).');
1376
+ } else {
1362
1377
  console.log('⚠️ External ping FAILED (server may not be publicly reachable yet).');
1363
- console.log('Fix ingress (DNS/reverse proxy/firewall), then rerun with:');
1378
+ console.log('Fix ingress (DNS/reverse proxy/firewall), then rerun. If you want to proceed anyway:');
1364
1379
  console.log(` a2a quickstart --hostname ${inviteHost} --port ${backendPort} --confirm-ingress`);
1380
+ console.log(` a2a quickstart --hostname ${inviteHost} --port ${backendPort} --skip-verify`);
1365
1381
  console.log('');
1366
1382
  return;
1367
- } else {
1368
- console.log('⚠️ External ping FAILED (skipped via --skip-verify).');
1369
- }
1370
- }
1383
+ }
1384
+ }
1371
1385
 
1372
1386
  if (!config.getOnboarding().verify_confirmed) {
1373
1387
  config.setOnboarding({
@@ -1496,46 +1510,73 @@ https://github.com/onthegonow/a2a_calling`;
1496
1510
  }
1497
1511
  },
1498
1512
 
1499
- onboard: (args) => {
1500
- const { A2AConfig } = require('../src/lib/config');
1501
- const { readContextFiles, generateDefaultManifest, saveManifest, MANIFEST_FILE } = require('../src/lib/disclosure');
1502
- const config = new A2AConfig();
1503
-
1504
- if (config.isOnboarded() && !args.flags.force) {
1505
- console.log('\u2705 Onboarding already complete. Use --force to regenerate the disclosure manifest.');
1506
- return;
1507
- }
1513
+ onboard: (args) => {
1514
+ const { A2AConfig } = require('../src/lib/config');
1515
+ const {
1516
+ readContextFiles,
1517
+ buildExtractionPrompt,
1518
+ validateDisclosureSubmission,
1519
+ saveManifest,
1520
+ MANIFEST_FILE
1521
+ } = require('../src/lib/disclosure');
1522
+ const config = new A2AConfig();
1508
1523
 
1509
- const workspaceDir = process.env.A2A_WORKSPACE || process.cwd();
1510
- console.log('\n\ud83d\ude80 A2A Onboarding\n' + '\u2550'.repeat(50) + '\n');
1511
- console.log('Scanning workspace for context...\n');
1524
+ // ── Submit mode: agent sends structured JSON ──────────────
1525
+ const submitRaw = args.flags.submit;
1526
+ if (submitRaw) {
1527
+ let parsed;
1528
+ try {
1529
+ parsed = JSON.parse(String(submitRaw));
1530
+ } catch (e) {
1531
+ console.error('\n\u274c Invalid JSON in --submit flag.');
1532
+ console.error(` Parse error: ${e.message}\n`);
1533
+ process.exit(1);
1534
+ }
1535
+
1536
+ const result = validateDisclosureSubmission(parsed);
1537
+ if (!result.valid) {
1538
+ console.error('\n\u274c Disclosure submission validation failed:\n');
1539
+ result.errors.forEach(err => console.error(` \u2022 ${err}`));
1540
+ console.error(`\nFix the errors above and resubmit with: a2a onboard --submit '<json>'\n`);
1541
+ process.exit(1);
1542
+ }
1543
+
1544
+ saveManifest(result.manifest);
1545
+
1546
+ const agentName = args.flags.name || config.getAgent().name || process.env.A2A_AGENT_NAME || '';
1547
+ const hostname = args.flags.hostname || config.getAgent().hostname || process.env.A2A_HOSTNAME || '';
1548
+ if (agentName) config.setAgent({ name: agentName });
1549
+ if (hostname) config.setAgent({ hostname });
1512
1550
 
1551
+ console.log('\n\u2705 Disclosure manifest saved.');
1552
+ console.log(` Manifest: ${MANIFEST_FILE}`);
1553
+ console.log(' Next: a2a quickstart\n');
1554
+ return;
1555
+ }
1556
+
1557
+ // ── Prompt mode: print extraction instructions for agent ──
1558
+ if (config.isOnboarded() && !args.flags.force) {
1559
+ console.log('\u2705 Onboarding already complete. Use --force to regenerate.');
1560
+ return;
1561
+ }
1562
+
1563
+ const workspaceDir = process.env.A2A_WORKSPACE || process.cwd();
1513
1564
  const contextFiles = readContextFiles(workspaceDir);
1514
- // Print what was found
1515
- const sources = {
1516
- 'USER.md': contextFiles.user,
1517
- 'HEARTBEAT.md': contextFiles.heartbeat,
1518
- 'SOUL.md': contextFiles.soul,
1519
- 'SKILL.md': contextFiles.skill,
1520
- 'CLAUDE.md': contextFiles.claude,
1521
- 'memory/*.md': contextFiles.memory
1522
- };
1523
- for (const [name, content] of Object.entries(sources)) {
1524
- console.log(` ${content ? '\u2705' : '\u274c'} ${name}`);
1525
- }
1526
-
1527
- const manifest = generateDefaultManifest(contextFiles);
1528
- saveManifest(manifest);
1529
-
1530
- const agentName = args.flags.name || config.getAgent().name || process.env.A2A_AGENT_NAME || '';
1531
- const hostname = args.flags.hostname || config.getAgent().hostname || process.env.A2A_HOSTNAME || '';
1532
- if (agentName) config.setAgent({ name: agentName });
1533
- if (hostname) config.setAgent({ hostname });
1534
-
1535
- console.log(`\n\u2705 Disclosure manifest generated.`);
1536
- console.log(` Manifest: ${MANIFEST_FILE}`);
1537
- console.log(' Next: a2a quickstart\n');
1538
- },
1565
+
1566
+ const availableFiles = {
1567
+ 'USER.md': Boolean(contextFiles.user),
1568
+ 'SOUL.md': Boolean(contextFiles.soul),
1569
+ 'HEARTBEAT.md': Boolean(contextFiles.heartbeat),
1570
+ 'SKILL.md': Boolean(contextFiles.skill),
1571
+ 'CLAUDE.md': Boolean(contextFiles.claude),
1572
+ 'memory/*.md': Boolean(contextFiles.memory)
1573
+ };
1574
+
1575
+ console.log(buildExtractionPrompt(availableFiles));
1576
+ console.log('\n---');
1577
+ console.log('After the owner confirms, submit with:');
1578
+ console.log(" a2a onboard --submit '<json>'\n");
1579
+ },
1539
1580
 
1540
1581
  help: () => {
1541
1582
  console.log(`A2A Calling - Agent-to-Agent Communication
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -153,7 +153,7 @@ function ensureConfigAndManifest(inviteHost, port, options = {}) {
153
153
 
154
154
  try {
155
155
  const { A2AConfig } = require('../src/lib/config');
156
- const { loadManifest, saveManifest, generateDefaultManifest, readContextFiles } = require('../src/lib/disclosure');
156
+ const { loadManifest, saveManifest, generateDefaultManifest } = require('../src/lib/disclosure');
157
157
 
158
158
  const config = new A2AConfig();
159
159
  const defaults = config.getDefaults() || {};
@@ -167,8 +167,7 @@ function ensureConfigAndManifest(inviteHost, port, options = {}) {
167
167
 
168
168
  const manifest = loadManifest();
169
169
  if (!manifest || Object.keys(manifest).length === 0) {
170
- const contextFiles = readContextFiles(process.cwd());
171
- const generated = generateDefaultManifest(contextFiles);
170
+ const generated = generateDefaultManifest();
172
171
  saveManifest(generated);
173
172
  const manifestFile = path.join(configDir, 'a2a-disclosure.json');
174
173
  log(`Generated default disclosure manifest: ${manifestFile}`);
@@ -122,196 +122,170 @@ function formatTopicsForPrompt(tierTopics) {
122
122
  }
123
123
 
124
124
  /**
125
- * Generate a default manifest by reading USER.md, HEARTBEAT.md, SOUL.md
126
- * from the owner's workspace. Falls back to a minimal starter if files don't exist.
125
+ * Generate a minimal starter manifest. This provides safe defaults when
126
+ * no agent-driven extraction has been performed yet.
127
+ *
128
+ * For proper topic extraction, use buildExtractionPrompt() to instruct
129
+ * an agent, then validate the result with validateDisclosureSubmission().
127
130
  */
128
- function generateDefaultManifest(contextFiles = {}) {
131
+ function generateDefaultManifest() {
129
132
  const now = new Date().toISOString();
130
133
 
131
- const manifest = {
134
+ return {
132
135
  version: 1,
133
136
  generated_at: now,
134
137
  updated_at: now,
135
138
  topics: {
136
- public: { lead_with: [], discuss_freely: [], deflect: [] },
139
+ public: {
140
+ lead_with: [{ topic: 'What I do', detail: 'Brief professional description' }],
141
+ discuss_freely: [{ topic: 'General interests', detail: 'Non-sensitive topics and hobbies' }],
142
+ deflect: [{ topic: 'Personal details', detail: 'Redirect to direct owner contact' }]
143
+ },
137
144
  friends: { lead_with: [], discuss_freely: [], deflect: [] },
138
145
  family: { lead_with: [], discuss_freely: [], deflect: [] }
139
146
  },
140
147
  never_disclose: ['API keys', 'Other users\' data', 'Financial figures'],
141
148
  personality_notes: 'Direct and technical. Prefers depth over breadth.'
142
149
  };
150
+ }
151
+
152
+ /**
153
+ * Check if a string contains technical content that shouldn't appear in
154
+ * disclosure topics (code snippets, URLs, markdown formatting, camelCase identifiers).
155
+ */
156
+ function isTechnicalContent(line) {
157
+ return /`/.test(line) ||
158
+ /https?:\/\//.test(line) ||
159
+ /\*\*:/.test(line) ||
160
+ /:\*\*/.test(line) ||
161
+ /\b[a-z]{3,}[A-Z][a-z]{3,}/.test(line);
162
+ }
143
163
 
144
- const userContent = contextFiles.user || '';
145
- const heartbeatContent = contextFiles.heartbeat || '';
146
- const soulContent = contextFiles.soul || '';
147
-
148
- const hasContent = userContent || heartbeatContent || soulContent ||
149
- contextFiles.memory || contextFiles.claude;
150
-
151
- if (!hasContent) {
152
- // Minimal starter manifest
153
- manifest.topics.public.lead_with.push(
154
- { topic: 'What I do', detail: 'Brief professional description' }
155
- );
156
- manifest.topics.public.discuss_freely.push(
157
- { topic: 'General interests', detail: 'Non-sensitive topics and hobbies' }
158
- );
159
- manifest.topics.public.deflect.push(
160
- { topic: 'Personal details', detail: 'Redirect to direct owner contact' }
161
- );
162
- return manifest;
164
+ /**
165
+ * Validate an agent-submitted disclosure submission against the expected schema.
166
+ * Returns { valid: boolean, manifest: object|null, errors: string[] }.
167
+ */
168
+ function validateDisclosureSubmission(data) {
169
+ const errors = [];
170
+
171
+ // Must be a non-null object
172
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
173
+ return { valid: false, manifest: null, errors: ['Submission must be a non-null object'] };
163
174
  }
164
175
 
165
- // Extract from USER.md
166
- if (userContent) {
167
- // Goals/seeking
168
- const goalsMatch = userContent.match(/##\s*(?:Goals|Current|Seeking|Working On)[^\n]*\n([\s\S]*?)(?=\n##|$)/i);
169
- if (goalsMatch) {
170
- const goals = goalsMatch[1]
171
- .split('\n')
172
- .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
173
- .map(l => l.replace(/^[\s\-\*]+/, '').trim())
174
- .filter(Boolean);
175
-
176
- goals.forEach((goal, i) => {
177
- if (i < 2) {
178
- manifest.topics.public.lead_with.push({ topic: goal.slice(0, 60), detail: goal });
179
- } else {
180
- manifest.topics.public.discuss_freely.push({ topic: goal.slice(0, 60), detail: goal });
181
- }
182
- });
183
- }
176
+ // Require topics object
177
+ if (!data.topics || typeof data.topics !== 'object' || Array.isArray(data.topics)) {
178
+ errors.push('Submission must include a "topics" object');
179
+ return { valid: false, manifest: null, errors };
180
+ }
184
181
 
185
- // Interests/projects
186
- const interestsMatch = userContent.match(/##\s*(?:Interests|Projects|Skills)[^\n]*\n([\s\S]*?)(?=\n##|$)/i);
187
- if (interestsMatch) {
188
- const interests = interestsMatch[1]
189
- .split('\n')
190
- .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
191
- .map(l => l.replace(/^[\s\-\*]+/, '').trim())
192
- .filter(Boolean);
193
-
194
- interests.forEach(interest => {
195
- manifest.topics.public.discuss_freely.push({ topic: interest.slice(0, 60), detail: interest });
196
- });
182
+ // Require all three tiers
183
+ for (const tier of TIER_HIERARCHY) {
184
+ if (!data.topics[tier] || typeof data.topics[tier] !== 'object') {
185
+ errors.push(`Missing required tier: "${tier}" in topics`);
197
186
  }
187
+ }
188
+ if (errors.length > 0) {
189
+ return { valid: false, manifest: null, errors };
190
+ }
198
191
 
199
- // Private/personal sections go to friends/family
200
- const privateMatch = userContent.match(/##\s*(?:Private|Personal|Family)[^\n]*\n([\s\S]*?)(?=\n##|$)/i);
201
- if (privateMatch) {
202
- const privateItems = privateMatch[1]
203
- .split('\n')
204
- .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
205
- .map(l => l.replace(/^[\s\-\*]+/, '').trim())
206
- .filter(Boolean);
207
-
208
- privateItems.forEach(item => {
209
- manifest.topics.family.discuss_freely.push({ topic: item.slice(0, 60), detail: item });
210
- });
211
-
212
- // Deflect these for public
213
- manifest.topics.public.deflect.push(
214
- { topic: 'Personal life', detail: 'Redirect — suggest owners connect directly' }
215
- );
216
- }
192
+ // Reject extra tiers beyond the known hierarchy
193
+ const extraTiers = Object.keys(data.topics).filter(t => !TIER_HIERARCHY.includes(t));
194
+ if (extraTiers.length > 0) {
195
+ errors.push(`Unknown tiers: ${extraTiers.join(', ')} — only public, friends, family are allowed`);
217
196
  }
218
197
 
219
- // Extract from HEARTBEAT.md (recent activity/status)
220
- if (heartbeatContent) {
221
- const recentLines = heartbeatContent
222
- .split('\n')
223
- .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
224
- .map(l => l.replace(/^[\s\-\*]+/, '').trim())
225
- .filter(Boolean)
226
- .slice(0, 5);
227
-
228
- recentLines.forEach((line, i) => {
229
- if (i < 2) {
230
- manifest.topics.public.lead_with.push({ topic: line.slice(0, 60), detail: line });
231
- } else {
232
- manifest.topics.friends.discuss_freely.push({ topic: line.slice(0, 60), detail: line });
198
+ // Validate each tier's structure
199
+ const requiredLists = ['lead_with', 'discuss_freely', 'deflect'];
200
+ const LIST_LIMITS = { lead_with: 10, discuss_freely: 20, deflect: 10 };
201
+ for (const tier of TIER_HIERARCHY) {
202
+ const tierData = data.topics[tier];
203
+ for (const cat of requiredLists) {
204
+ if (!Array.isArray(tierData[cat])) {
205
+ errors.push(`topics.${tier}.${cat} must be an array`);
206
+ continue;
233
207
  }
234
- });
208
+ if (tierData[cat].length > LIST_LIMITS[cat]) {
209
+ errors.push(`topics.${tier}.${cat} has ${tierData[cat].length} items — max ${LIST_LIMITS[cat]}`);
210
+ }
211
+ for (let i = 0; i < tierData[cat].length; i++) {
212
+ const item = tierData[cat][i];
213
+ if (!item || typeof item !== 'object' || typeof item.topic !== 'string' || typeof item.detail !== 'string') {
214
+ errors.push(`topics.${tier}.${cat}[${i}]: each topic item must have "topic" (string) and "detail" (string)`);
215
+ continue;
216
+ }
217
+ if (item.topic.trim().length === 0) {
218
+ errors.push(`topics.${tier}.${cat}[${i}].topic must not be empty`);
219
+ continue;
220
+ }
221
+ if (item.topic.length > 160) {
222
+ errors.push(`topics.${tier}.${cat}[${i}]: topic exceeds 160 character limit (got ${item.topic.length})`);
223
+ }
224
+ if (item.detail.length > 500) {
225
+ errors.push(`topics.${tier}.${cat}[${i}]: detail exceeds 500 character limit (got ${item.detail.length})`);
226
+ }
227
+ if (isTechnicalContent(item.topic) || isTechnicalContent(item.detail)) {
228
+ errors.push(`topics.${tier}.${cat}[${i}]: contains technical content (code, URLs, or markdown formatting) — use plain language`);
229
+ }
230
+ }
231
+ }
235
232
  }
236
233
 
237
- // Extract from SOUL.md (personality, values)
238
- if (soulContent) {
239
- // Look for personality cues
240
- const personalityLines = soulContent
241
- .split('\n')
242
- .filter(l => l.trim() && !l.startsWith('#'))
243
- .slice(0, 3)
244
- .join(' ')
245
- .trim();
246
-
247
- if (personalityLines) {
248
- manifest.personality_notes = personalityLines.slice(0, 300);
234
+ // Validate never_disclose (optional, defaults to sensible list)
235
+ if (data.never_disclose !== undefined) {
236
+ if (!Array.isArray(data.never_disclose)) {
237
+ errors.push('"never_disclose" must be an array of strings');
238
+ } else {
239
+ if (data.never_disclose.length > 20) {
240
+ errors.push('never_disclose has too many items — max 20');
241
+ }
242
+ for (let i = 0; i < data.never_disclose.length; i++) {
243
+ if (typeof data.never_disclose[i] !== 'string') {
244
+ errors.push(`never_disclose[${i}] must be a string`);
245
+ } else if (data.never_disclose[i].length > 200) {
246
+ errors.push(`never_disclose[${i}] exceeds 200 chars`);
247
+ }
248
+ }
249
249
  }
250
+ }
250
251
 
251
- // Values become friends-tier topics
252
- const valuesMatch = soulContent.match(/##\s*(?:Values|Beliefs|Principles)[^\n]*\n([\s\S]*?)(?=\n##|$)/i);
253
- if (valuesMatch) {
254
- const values = valuesMatch[1]
255
- .split('\n')
256
- .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
257
- .map(l => l.replace(/^[\s\-\*]+/, '').trim())
258
- .filter(Boolean);
259
-
260
- values.forEach(value => {
261
- manifest.topics.friends.discuss_freely.push({ topic: value.slice(0, 60), detail: value });
262
- });
252
+ // Validate personality_notes (optional)
253
+ if (data.personality_notes !== undefined) {
254
+ if (typeof data.personality_notes !== 'string') {
255
+ errors.push('"personality_notes" must be a string');
256
+ } else if (data.personality_notes.length > 500) {
257
+ errors.push('"personality_notes" exceeds 500 chars');
263
258
  }
264
259
  }
265
260
 
266
- // Extract topic keywords from memory files
267
- if (contextFiles.memory) {
268
- const memoryLines = contextFiles.memory
269
- .split('\n')
270
- .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
271
- .map(l => l.replace(/^[\s\-\*]+/, '').trim())
272
- .filter(l => l.length > 5 && l.length < 120)
273
- .slice(0, 4);
274
-
275
- memoryLines.forEach(item => {
276
- manifest.topics.friends.discuss_freely.push({ topic: item.slice(0, 60), detail: item });
277
- });
261
+ if (errors.length > 0) {
262
+ return { valid: false, manifest: null, errors };
278
263
  }
279
264
 
280
- // Extract project context from CLAUDE.md
281
- if (contextFiles.claude) {
282
- const claudeMatch = contextFiles.claude.match(/##\s*(?:What|Quick|About|Context)[^\n]*\n([\s\S]*?)(?=\n##|$)/i);
283
- if (claudeMatch) {
284
- const contextLines = claudeMatch[1]
285
- .split('\n')
286
- .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*') || (l.trim().length > 10 && !l.startsWith('#')))
287
- .map(l => l.replace(/^[\s\-\*]+/, '').trim())
288
- .filter(Boolean)
289
- .slice(0, 3);
290
-
291
- contextLines.forEach(item => {
292
- manifest.topics.public.discuss_freely.push({ topic: item.slice(0, 60), detail: item });
293
- });
265
+ // Rebuild topics from only validated keys to prevent extra properties passing through
266
+ const cleanTopics = {};
267
+ for (const tier of TIER_HIERARCHY) {
268
+ cleanTopics[tier] = {};
269
+ for (const cat of ['lead_with', 'discuss_freely', 'deflect']) {
270
+ cleanTopics[tier][cat] = (data.topics[tier][cat] || []).map(item => ({
271
+ topic: item.topic,
272
+ detail: item.detail
273
+ }));
294
274
  }
295
275
  }
296
276
 
297
- // Ensure at least something in each public category
298
- if (manifest.topics.public.lead_with.length === 0) {
299
- manifest.topics.public.lead_with.push(
300
- { topic: 'Current focus', detail: 'Primary work and interests' }
301
- );
302
- }
303
- if (manifest.topics.public.discuss_freely.length === 0) {
304
- manifest.topics.public.discuss_freely.push(
305
- { topic: 'General interests', detail: 'Non-sensitive topics' }
306
- );
307
- }
308
- if (manifest.topics.public.deflect.length === 0) {
309
- manifest.topics.public.deflect.push(
310
- { topic: 'Private matters', detail: 'Redirect to direct owner contact' }
311
- );
312
- }
277
+ // Build valid manifest
278
+ const now = new Date().toISOString();
279
+ const manifest = {
280
+ version: 1,
281
+ generated_at: now,
282
+ updated_at: now,
283
+ topics: cleanTopics,
284
+ never_disclose: data.never_disclose || ['API keys', 'Other users\' data', 'Financial figures'],
285
+ personality_notes: data.personality_notes || ''
286
+ };
313
287
 
314
- return manifest;
288
+ return { valid: true, manifest, errors: [] };
315
289
  }
316
290
 
317
291
  /**
@@ -355,6 +329,72 @@ function readContextFiles(workspaceDir) {
355
329
  return result;
356
330
  }
357
331
 
332
+ /**
333
+ * Generate the extraction prompt that instructs an agent on exactly what
334
+ * structured disclosure data to return.
335
+ *
336
+ * @param {Object} [availableFiles] - Map of filename to truthy if present
337
+ * @returns {string} The instruction prompt for the agent
338
+ */
339
+ function buildExtractionPrompt(availableFiles = {}) {
340
+ const fileList = Object.entries(availableFiles)
341
+ .filter(([, present]) => present)
342
+ .map(([name]) => ` - ${name}`)
343
+ .join('\n') || ' (no workspace files detected)';
344
+
345
+ const jsonBlock = '```json\n{\n "topics": {\n "public": {\n "lead_with": [\n { "topic": "Short label (max 160 chars)", "detail": "Longer description of the topic" }\n ],\n "discuss_freely": [],\n "deflect": []\n },\n "friends": {\n "lead_with": [],\n "discuss_freely": [],\n "deflect": []\n },\n "family": {\n "lead_with": [],\n "discuss_freely": [],\n "deflect": []\n }\n },\n "never_disclose": ["API keys", "Credentials", "Financial figures"],\n "personality_notes": "Brief description of communication style"\n}\n```';
346
+
347
+ return `## A2A Disclosure Extraction
348
+
349
+ You are helping the owner set up their A2A disclosure profile — the topics and information their agent is willing to discuss with other agents at different trust levels.
350
+
351
+ ### Available workspace files
352
+ ${fileList}
353
+
354
+ Read the available files above and extract disclosure topics. Focus on what the OWNER cares about, works on, and wants to discuss — NOT on agent instructions, code documentation, or operational tasks.
355
+
356
+ ### What to extract
357
+
358
+ For each trust tier, identify topics the owner would want to discuss:
359
+
360
+ - **public** — safe for anyone: professional role, public interests, general project descriptions
361
+ - **friends** — for trusted contacts: current goals, collaboration interests, values, detailed project work
362
+ - **family** — inner circle only: personal interests, private projects, sensitive plans
363
+
364
+ For each tier, categorize topics as:
365
+ - **lead_with** — proactively bring up (max 3 per tier)
366
+ - **discuss_freely** — happy to discuss if asked (max 8 per tier)
367
+ - **deflect** — redirect or decline (max 3 per tier)
368
+
369
+ Also identify:
370
+ - **never_disclose** — information that should never be shared regardless of tier (API keys, credentials, financial data, etc.)
371
+ - **personality_notes** — a 1-2 sentence description of the owner's communication style
372
+
373
+ ### What NOT to extract
374
+
375
+ Do NOT include as topics:
376
+ - Code snippets, CLI commands, or technical documentation
377
+ - URLs or file paths
378
+ - Agent instructions or operational tasks (e.g., "post 50 comments/day")
379
+ - Markdown formatting artifacts (bold markers, backticks)
380
+ - Anything from HEARTBEAT.md (these are agent tasks, not disclosure topics)
381
+
382
+ ### Required JSON format
383
+
384
+ Return ONLY valid JSON in this exact structure:
385
+
386
+ ${jsonBlock}
387
+
388
+ ### Rules
389
+
390
+ 1. Each "topic" string must be a short, human-readable label (max 160 chars)
391
+ 2. Each "detail" string explains the topic more fully (max 500 chars)
392
+ 3. Topics should be things a person would discuss, not technical artifacts
393
+ 4. Higher tiers (friends, family) inherit lower-tier topics automatically — don't duplicate
394
+ 5. Present this to the owner for review before submitting
395
+ 6. The owner may edit, remove, or add topics before final submission`;
396
+ }
397
+
358
398
  module.exports = {
359
399
  loadManifest,
360
400
  saveManifest,
@@ -362,5 +402,8 @@ module.exports = {
362
402
  formatTopicsForPrompt,
363
403
  generateDefaultManifest,
364
404
  readContextFiles,
405
+ validateDisclosureSubmission,
406
+ isTechnicalContent,
407
+ buildExtractionPrompt,
365
408
  MANIFEST_FILE
366
409
  };