@ouro.bot/cli 0.1.0-alpha.918 → 0.1.0-alpha.919
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/changelog.json +8 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/cli-exec.js +83 -14
- package/dist/heart/daemon/cli-help.js +3 -3
- package/dist/heart/daemon/cli-parse.js +67 -3
- package/dist/heart/identity.js +17 -1
- package/dist/mind/friend-link-codes.js +209 -0
- package/dist/mind/friend-merge.js +330 -0
- package/dist/repertoire/guardrails.js +8 -2
- package/dist/repertoire/tools-base.js +2 -0
- package/dist/repertoire/tools-friend-link.js +57 -0
- package/dist/repertoire/tools-shepherd.js +89 -7
- package/dist/senses/pipeline.js +30 -8
- package/dist/senses/shepherd/cmux.js +5 -3
- package/dist/senses/shepherd/herdr.js +1 -1
- package/dist/senses/shepherd/owner-notice.js +245 -0
- package/dist/senses/shepherd/redact.js +29 -0
- package/dist/senses/shepherd/sense.js +27 -15
- package/dist/senses/teams.js +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.externalIdKey = externalIdKey;
|
|
37
|
+
exports.mergeFriendRecordContent = mergeFriendRecordContent;
|
|
38
|
+
exports.mergeFriends = mergeFriends;
|
|
39
|
+
exports.planFriendDedupe = planFriendDedupe;
|
|
40
|
+
exports.dedupeFriends = dedupeFriends;
|
|
41
|
+
// Merging friend records into one person, and retiring the merged-away records.
|
|
42
|
+
//
|
|
43
|
+
// Two product paths use this: redeeming an identity link code (a new channel identity
|
|
44
|
+
// joins its owner's record) and deduplicating records that name the same local person.
|
|
45
|
+
// The target record stays authoritative: a merge never raises trust, admission,
|
|
46
|
+
// initiative or capability, so merging cannot be used to escalate anyone.
|
|
47
|
+
//
|
|
48
|
+
// A retired record is kept as a tombstone in `friends/_retired/` (outside the store's
|
|
49
|
+
// listing), never deleted silently. External-identity claim journals that pointed at the
|
|
50
|
+
// retired record move beside it, so the store's claim ledger never names a record that
|
|
51
|
+
// no longer exists. The claim journal layout belongs to @ouro.bot/friends; this module
|
|
52
|
+
// only moves whole journal files and never rewrites them.
|
|
53
|
+
const fs = __importStar(require("node:fs"));
|
|
54
|
+
const path = __importStar(require("node:path"));
|
|
55
|
+
const runtime_1 = require("../nerves/runtime");
|
|
56
|
+
const RETIRED_DIR = "_retired";
|
|
57
|
+
const CLAIMS_DIR = "_external-id-claims";
|
|
58
|
+
const CLAIM_FILE = /^[a-f0-9]{64}\.json$/;
|
|
59
|
+
function externalIdKey(externalId) {
|
|
60
|
+
return JSON.stringify([externalId.provider, externalId.externalId, externalId.tenantId ?? null]);
|
|
61
|
+
}
|
|
62
|
+
/** Fold the sources into the target. The target's relationship fields always win. */
|
|
63
|
+
function mergeFriendRecordContent(target, sources, nowIso) {
|
|
64
|
+
const externalIds = [...target.externalIds];
|
|
65
|
+
const seenIds = new Set(externalIds.map(externalIdKey));
|
|
66
|
+
const addedExternalIds = [];
|
|
67
|
+
const notes = { ...target.notes };
|
|
68
|
+
const preservedNoteKeys = [];
|
|
69
|
+
const tenantMemberships = [...target.tenantMemberships];
|
|
70
|
+
const toolPreferences = { ...target.toolPreferences };
|
|
71
|
+
const connections = [...(target.connections ?? [])];
|
|
72
|
+
const importedNotes = { ...(target.importedNotes ?? {}) };
|
|
73
|
+
let totalTokens = target.totalTokens;
|
|
74
|
+
let createdAt = target.createdAt;
|
|
75
|
+
for (const source of sources) {
|
|
76
|
+
for (const externalId of source.externalIds) {
|
|
77
|
+
const key = externalIdKey(externalId);
|
|
78
|
+
if (seenIds.has(key))
|
|
79
|
+
continue;
|
|
80
|
+
seenIds.add(key);
|
|
81
|
+
externalIds.push(externalId);
|
|
82
|
+
addedExternalIds.push(externalId);
|
|
83
|
+
}
|
|
84
|
+
for (const [key, note] of Object.entries(source.notes)) {
|
|
85
|
+
const existing = notes[key];
|
|
86
|
+
if (!existing) {
|
|
87
|
+
notes[key] = note;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (existing.value === note.value)
|
|
91
|
+
continue;
|
|
92
|
+
const preservedKey = `${key}~${source.id.slice(0, 8)}`;
|
|
93
|
+
if (notes[preservedKey])
|
|
94
|
+
continue;
|
|
95
|
+
notes[preservedKey] = note;
|
|
96
|
+
preservedNoteKeys.push(preservedKey);
|
|
97
|
+
}
|
|
98
|
+
for (const tenant of source.tenantMemberships) {
|
|
99
|
+
if (!tenantMemberships.includes(tenant))
|
|
100
|
+
tenantMemberships.push(tenant);
|
|
101
|
+
}
|
|
102
|
+
for (const [tool, preference] of Object.entries(source.toolPreferences)) {
|
|
103
|
+
if (!(tool in toolPreferences))
|
|
104
|
+
toolPreferences[tool] = preference;
|
|
105
|
+
}
|
|
106
|
+
for (const connection of source.connections ?? []) {
|
|
107
|
+
if (!connections.some((known) => known.name === connection.name && known.relationship === connection.relationship)) {
|
|
108
|
+
connections.push(connection);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const [agentKey, imported] of Object.entries(source.importedNotes ?? {})) {
|
|
112
|
+
importedNotes[agentKey] = { ...imported, ...(importedNotes[agentKey] ?? {}) };
|
|
113
|
+
}
|
|
114
|
+
totalTokens += source.totalTokens;
|
|
115
|
+
if (source.createdAt < createdAt)
|
|
116
|
+
createdAt = source.createdAt;
|
|
117
|
+
}
|
|
118
|
+
const merged = {
|
|
119
|
+
...target,
|
|
120
|
+
externalIds,
|
|
121
|
+
notes,
|
|
122
|
+
tenantMemberships,
|
|
123
|
+
toolPreferences,
|
|
124
|
+
connections,
|
|
125
|
+
...(Object.keys(importedNotes).length > 0 ? { importedNotes } : {}),
|
|
126
|
+
totalTokens,
|
|
127
|
+
createdAt,
|
|
128
|
+
updatedAt: nowIso,
|
|
129
|
+
};
|
|
130
|
+
return { merged, addedExternalIds, preservedNoteKeys };
|
|
131
|
+
}
|
|
132
|
+
function writePrivateJson(filePath, value) {
|
|
133
|
+
const temp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
134
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
135
|
+
fs.renameSync(temp, filePath);
|
|
136
|
+
}
|
|
137
|
+
function uniqueTombstonePath(retiredDir, friendId, nowIso) {
|
|
138
|
+
const plain = path.join(retiredDir, `${friendId}.json`);
|
|
139
|
+
if (!fs.existsSync(plain))
|
|
140
|
+
return plain;
|
|
141
|
+
return path.join(retiredDir, `${friendId}.${nowIso.replace(/[:.]/g, "-")}.json`);
|
|
142
|
+
}
|
|
143
|
+
/** Move every claim journal that names `friendId` out of the live claim ledger. */
|
|
144
|
+
function moveClaimJournals(friendsDir, friendId) {
|
|
145
|
+
const claimsDir = path.join(friendsDir, CLAIMS_DIR);
|
|
146
|
+
if (!fs.existsSync(claimsDir))
|
|
147
|
+
return [];
|
|
148
|
+
const retiredClaimsDir = path.join(friendsDir, RETIRED_DIR, "external-id-claims");
|
|
149
|
+
const moved = [];
|
|
150
|
+
for (const entry of fs.readdirSync(claimsDir)) {
|
|
151
|
+
if (!CLAIM_FILE.test(entry))
|
|
152
|
+
continue;
|
|
153
|
+
let claim;
|
|
154
|
+
try {
|
|
155
|
+
claim = JSON.parse(fs.readFileSync(path.join(claimsDir, entry), "utf-8"));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (claim.friendId !== friendId)
|
|
161
|
+
continue;
|
|
162
|
+
fs.mkdirSync(retiredClaimsDir, { recursive: true, mode: 0o700 });
|
|
163
|
+
fs.renameSync(path.join(claimsDir, entry), path.join(retiredClaimsDir, entry));
|
|
164
|
+
moved.push(entry);
|
|
165
|
+
}
|
|
166
|
+
return moved;
|
|
167
|
+
}
|
|
168
|
+
async function mergeFriends(input) {
|
|
169
|
+
const nowIso = (input.now ?? (() => new Date()))().toISOString();
|
|
170
|
+
const dryRun = input.dryRun === true;
|
|
171
|
+
const sourceIds = [...new Set(input.sourceIds)];
|
|
172
|
+
if (sourceIds.length === 0 || sourceIds.includes(input.targetId)) {
|
|
173
|
+
return { ok: false, reason: "invalid_sources", message: "name at least one record to merge, other than the target" };
|
|
174
|
+
}
|
|
175
|
+
const target = await input.store.get(input.targetId);
|
|
176
|
+
if (!target || target.id !== input.targetId) {
|
|
177
|
+
return { ok: false, reason: "target_not_found", message: `friend not found: ${input.targetId}` };
|
|
178
|
+
}
|
|
179
|
+
const sources = [];
|
|
180
|
+
for (const sourceId of sourceIds) {
|
|
181
|
+
const source = await input.store.get(sourceId);
|
|
182
|
+
if (!source || source.id !== sourceId) {
|
|
183
|
+
return { ok: false, reason: "source_not_found", message: `friend not found: ${sourceId}` };
|
|
184
|
+
}
|
|
185
|
+
if ((source.kind ?? "human") !== (target.kind ?? "human")) {
|
|
186
|
+
return { ok: false, reason: "kind_mismatch", message: `cannot merge ${sourceId} into ${target.id}: an agent and a human are different friends` };
|
|
187
|
+
}
|
|
188
|
+
sources.push(source);
|
|
189
|
+
}
|
|
190
|
+
const { merged, addedExternalIds, preservedNoteKeys } = mergeFriendRecordContent(target, sources, nowIso);
|
|
191
|
+
const retiredDir = path.join(input.friendsDir, RETIRED_DIR);
|
|
192
|
+
if (dryRun) {
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
dryRun,
|
|
196
|
+
target: merged,
|
|
197
|
+
addedExternalIds,
|
|
198
|
+
preservedNoteKeys,
|
|
199
|
+
retired: sources.map((source) => ({ friendId: source.id, name: source.name, tombstonePath: path.join(retiredDir, `${source.id}.json`), movedClaims: [] })),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// Order: tombstones first (nothing is lost if we stop), then the target gains the
|
|
203
|
+
// identities, then the sources leave the store and their claim journals follow them.
|
|
204
|
+
fs.mkdirSync(retiredDir, { recursive: true, mode: 0o700 });
|
|
205
|
+
const tombstones = sources.map((source) => {
|
|
206
|
+
const tombstonePath = uniqueTombstonePath(retiredDir, source.id, nowIso);
|
|
207
|
+
writePrivateJson(tombstonePath, {
|
|
208
|
+
schemaVersion: 1,
|
|
209
|
+
retiredAt: nowIso,
|
|
210
|
+
retiredInto: target.id,
|
|
211
|
+
actor: input.actor,
|
|
212
|
+
reason: input.reason,
|
|
213
|
+
record: source,
|
|
214
|
+
});
|
|
215
|
+
return { source, tombstonePath };
|
|
216
|
+
});
|
|
217
|
+
await input.store.put(target.id, merged);
|
|
218
|
+
const retired = [];
|
|
219
|
+
for (const { source, tombstonePath } of tombstones) {
|
|
220
|
+
await input.store.delete(source.id);
|
|
221
|
+
const movedClaims = moveClaimJournals(input.friendsDir, source.id);
|
|
222
|
+
retired.push({ friendId: source.id, name: source.name, tombstonePath, movedClaims });
|
|
223
|
+
(0, runtime_1.emitNervesEvent)({
|
|
224
|
+
component: "friends",
|
|
225
|
+
event: "friends.record_retired",
|
|
226
|
+
message: "retired a friend record into another record",
|
|
227
|
+
meta: { friendId: source.id, retiredInto: target.id, actor: input.actor, movedClaims: movedClaims.length },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const stored = (await input.store.get(target.id)) ?? merged;
|
|
231
|
+
(0, runtime_1.emitNervesEvent)({
|
|
232
|
+
component: "friends",
|
|
233
|
+
event: "friends.records_merged",
|
|
234
|
+
message: "merged friend records",
|
|
235
|
+
meta: {
|
|
236
|
+
targetId: target.id,
|
|
237
|
+
sourceIds,
|
|
238
|
+
actor: input.actor,
|
|
239
|
+
reason: input.reason,
|
|
240
|
+
addedExternalIds: addedExternalIds.map((externalId) => externalId.provider),
|
|
241
|
+
preservedNoteKeys: preservedNoteKeys.length,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
return { ok: true, dryRun, target: stored, addedExternalIds, preservedNoteKeys, retired };
|
|
245
|
+
}
|
|
246
|
+
function isLocalOnly(record) {
|
|
247
|
+
return record.externalIds.length > 0 && record.externalIds.every((externalId) => externalId.provider === "local");
|
|
248
|
+
}
|
|
249
|
+
function canonicalOrder(a, b) {
|
|
250
|
+
const notes = Object.keys(b.notes).length - Object.keys(a.notes).length;
|
|
251
|
+
if (notes !== 0)
|
|
252
|
+
return notes;
|
|
253
|
+
if (a.createdAt !== b.createdAt)
|
|
254
|
+
return a.createdAt < b.createdAt ? -1 : 1;
|
|
255
|
+
return a.id < b.id ? -1 : 1;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Records are duplicates when they are human, carry only local identities, share the
|
|
259
|
+
* same name and trust level, and are joined through at least one shared local identity.
|
|
260
|
+
* The canonical record is the one with the most notes, then the oldest.
|
|
261
|
+
*/
|
|
262
|
+
function planFriendDedupe(records) {
|
|
263
|
+
const buckets = new Map();
|
|
264
|
+
for (const record of records) {
|
|
265
|
+
if ((record.kind ?? "human") !== "human" || !isLocalOnly(record))
|
|
266
|
+
continue;
|
|
267
|
+
const key = JSON.stringify([record.name.trim().toLowerCase(), record.trustLevel ?? "friend"]);
|
|
268
|
+
buckets.set(key, [...(buckets.get(key) ?? []), record]);
|
|
269
|
+
}
|
|
270
|
+
const groups = [];
|
|
271
|
+
for (const bucket of buckets.values()) {
|
|
272
|
+
// Union records that share any local identity.
|
|
273
|
+
const parent = bucket.map((_, index) => index);
|
|
274
|
+
const find = (index) => (parent[index] === index ? index : (parent[index] = find(parent[index])));
|
|
275
|
+
const owner = new Map();
|
|
276
|
+
bucket.forEach((record, index) => {
|
|
277
|
+
for (const externalId of record.externalIds) {
|
|
278
|
+
const key = externalIdKey(externalId);
|
|
279
|
+
const seen = owner.get(key);
|
|
280
|
+
if (seen === undefined)
|
|
281
|
+
owner.set(key, index);
|
|
282
|
+
else
|
|
283
|
+
parent[find(index)] = find(seen);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
const components = new Map();
|
|
287
|
+
bucket.forEach((record, index) => {
|
|
288
|
+
const root = find(index);
|
|
289
|
+
components.set(root, [...(components.get(root) ?? []), record]);
|
|
290
|
+
});
|
|
291
|
+
for (const members of components.values()) {
|
|
292
|
+
if (members.length < 2)
|
|
293
|
+
continue;
|
|
294
|
+
const [canonical, ...duplicates] = [...members].sort(canonicalOrder);
|
|
295
|
+
groups.push({ canonical: canonical, duplicates });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return groups.sort((a, b) => canonicalOrder(a.canonical, b.canonical));
|
|
299
|
+
}
|
|
300
|
+
async function dedupeFriends(input) {
|
|
301
|
+
const listAll = input.store.listAll;
|
|
302
|
+
const records = listAll ? await listAll.call(input.store) : [];
|
|
303
|
+
const plan = planFriendDedupe(records);
|
|
304
|
+
const dryRun = input.dryRun !== false;
|
|
305
|
+
const groups = [];
|
|
306
|
+
for (const group of plan) {
|
|
307
|
+
const duplicateIds = group.duplicates.map((record) => record.id);
|
|
308
|
+
const result = await mergeFriends({
|
|
309
|
+
store: input.store,
|
|
310
|
+
friendsDir: input.friendsDir,
|
|
311
|
+
targetId: group.canonical.id,
|
|
312
|
+
sourceIds: duplicateIds,
|
|
313
|
+
actor: input.actor,
|
|
314
|
+
reason: "duplicate local records of the same person",
|
|
315
|
+
dryRun,
|
|
316
|
+
...(input.now ? { now: input.now } : {}),
|
|
317
|
+
});
|
|
318
|
+
/* v8 ignore next -- the plan only names records it just listed; a concurrent removal is the only way here @preserve */
|
|
319
|
+
if (!result.ok)
|
|
320
|
+
throw new Error(result.message);
|
|
321
|
+
groups.push({ canonical: result.target, duplicateIds, preservedNoteKeys: result.preservedNoteKeys, addedExternalIds: result.addedExternalIds });
|
|
322
|
+
}
|
|
323
|
+
(0, runtime_1.emitNervesEvent)({
|
|
324
|
+
component: "friends",
|
|
325
|
+
event: "friends.dedupe_completed",
|
|
326
|
+
message: dryRun ? "planned friend record dedupe" : "applied friend record dedupe",
|
|
327
|
+
meta: { dryRun, groups: groups.length, duplicates: groups.reduce((sum, group) => sum + group.duplicateIds.length, 0), actor: input.actor },
|
|
328
|
+
});
|
|
329
|
+
return { dryRun, groups };
|
|
330
|
+
}
|
|
@@ -307,6 +307,11 @@ exports.OURO_CLI_TRUST_MANIFEST = {
|
|
|
307
307
|
"friend show": "friend",
|
|
308
308
|
"friend create": "friend",
|
|
309
309
|
"friend update": "family",
|
|
310
|
+
// Linking and merging change who the agent believes someone is: family only.
|
|
311
|
+
"friend link": "family",
|
|
312
|
+
"friend unlink": "family",
|
|
313
|
+
"friend merge": "family",
|
|
314
|
+
"friend dedupe": "family",
|
|
310
315
|
"config model": "friend",
|
|
311
316
|
"config models": "friend",
|
|
312
317
|
"mcp list": "acquaintance",
|
|
@@ -341,7 +346,8 @@ function resolveOuroSubcommand(command) {
|
|
|
341
346
|
/* v8 ignore next -- bare "ouro" is caught upstream by checkShellTrustGuardrails @preserve */
|
|
342
347
|
if (!afterOuro)
|
|
343
348
|
return null;
|
|
344
|
-
|
|
349
|
+
// `--agent <name>` may come before the subcommand; it never changes which subcommand runs.
|
|
350
|
+
const tokens = afterOuro.split(/\s+/).filter((token, index, all) => token !== "--agent" && all[index - 1] !== "--agent");
|
|
345
351
|
const twoWord = tokens.length >= 2 ? `${tokens[0]} ${tokens[1]}` : null;
|
|
346
352
|
// Two-word match first (e.g. "task board"), then one-word (e.g. "whoami")
|
|
347
353
|
if (twoWord && exports.OURO_CLI_TRUST_MANIFEST[twoWord])
|
|
@@ -443,7 +449,7 @@ const COMMERCE_FAMILY_TOOLS = new Set(["commerce_checkout_preview", "commerce_ch
|
|
|
443
449
|
const COMMERCE_AUTHORITY_TOOLS = new Set(["stripe_create_card", "flight_hold", "flight_book"]);
|
|
444
450
|
// The Shepherd tools read the owner's terminals and speak to them through their desktop, so only family may use them.
|
|
445
451
|
// A call with no known trust level fails closed.
|
|
446
|
-
const SHEPHERD_FAMILY_TOOLS = new Set(["shepherd_overview", "shepherd_read", "shepherd_signal"]);
|
|
452
|
+
const SHEPHERD_FAMILY_TOOLS = new Set(["shepherd_overview", "shepherd_read", "shepherd_signal", "shepherd_send"]);
|
|
447
453
|
const MAIL_FAMILY_TOOLS = new Set(["mail_screener", "mail_decide", "mail_access_log", "mail_send", "mail_index_refresh"]);
|
|
448
454
|
const MAIL_DELEGATED_READ_TOOLS = new Set(["mail_recent", "mail_search"]);
|
|
449
455
|
function opaqueAnsiShellAtoms(command) {
|
|
@@ -32,6 +32,7 @@ const tools_orientation_1 = require("./tools-orientation");
|
|
|
32
32
|
const tools_rsvp_1 = require("./tools-rsvp");
|
|
33
33
|
const tools_habits_1 = require("./tools-habits");
|
|
34
34
|
const tools_telegram_contacts_1 = require("./tools-telegram-contacts");
|
|
35
|
+
const tools_friend_link_1 = require("./tools-friend-link");
|
|
35
36
|
// Re-export flow tools for consumers that import them from tools-base
|
|
36
37
|
var tools_flow_1 = require("./tools-flow");
|
|
37
38
|
Object.defineProperty(exports, "ponderTool", { enumerable: true, get: function () { return tools_flow_1.ponderTool; } });
|
|
@@ -86,6 +87,7 @@ exports.baseToolDefinitions = [
|
|
|
86
87
|
...tools_rsvp_1.rsvpToolDefinitions,
|
|
87
88
|
...tools_habits_1.habitToolDefinitions,
|
|
88
89
|
tools_telegram_contacts_1.telegramContactToolDefinition,
|
|
90
|
+
tools_friend_link_1.friendLinkToolDefinition,
|
|
89
91
|
];
|
|
90
92
|
// Convenience array of just the tool schemas (no handler/integration metadata).
|
|
91
93
|
// Used by consumers that need the OpenAI function-tool format.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.friendLinkToolDefinition = void 0;
|
|
4
|
+
const identity_1 = require("../heart/identity");
|
|
5
|
+
const friend_link_codes_1 = require("../mind/friend-link-codes");
|
|
6
|
+
const runtime_1 = require("../nerves/runtime");
|
|
7
|
+
const LINKABLE_CHANNELS = ["teams", "bluebubbles", "telegram", "mail", "voice", "mcp", "cli"];
|
|
8
|
+
exports.friendLinkToolDefinition = {
|
|
9
|
+
tool: {
|
|
10
|
+
type: "function",
|
|
11
|
+
function: {
|
|
12
|
+
name: "friend_link_code",
|
|
13
|
+
description: "Issue a one-time code that links another account of the person you are talking to (for example their Teams account) to them. " +
|
|
14
|
+
"Give them the code; when they send exactly that code to you from the other account in a 1:1 chat, that account is treated as them. " +
|
|
15
|
+
"Only for family, only for themselves, and never in a group chat. The code expires in 10 minutes and works once.",
|
|
16
|
+
parameters: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
channel: {
|
|
20
|
+
type: "string",
|
|
21
|
+
enum: [...LINKABLE_CHANNELS],
|
|
22
|
+
description: "The channel the other account will message from. Omit to accept any channel.",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
handler: async (args, ctx) => {
|
|
29
|
+
(0, runtime_1.emitNervesEvent)({
|
|
30
|
+
component: "repertoire",
|
|
31
|
+
event: "repertoire.tool_friend_link_code",
|
|
32
|
+
message: "friend_link_code invoked",
|
|
33
|
+
meta: { channel: args.channel ?? "any" },
|
|
34
|
+
});
|
|
35
|
+
const context = ctx?.context;
|
|
36
|
+
if (!context?.friend)
|
|
37
|
+
return "no friend context: I can only issue a link code to the person I'm talking to.";
|
|
38
|
+
const friend = context.friend;
|
|
39
|
+
if (friend.trustLevel !== "family")
|
|
40
|
+
return "link codes are only for family.";
|
|
41
|
+
if (context.isGroupChat)
|
|
42
|
+
return "link codes can't be issued in a group chat. Ask me in a 1:1 chat.";
|
|
43
|
+
const channel = args.channel;
|
|
44
|
+
if (channel && !LINKABLE_CHANNELS.includes(channel))
|
|
45
|
+
return `unknown channel '${channel}'. Use one of: ${LINKABLE_CHANNELS.join(", ")}.`;
|
|
46
|
+
const issued = (0, friend_link_codes_1.issueLinkCode)({
|
|
47
|
+
agentRoot: ctx?.agentRoot ?? (0, identity_1.getAgentRoot)(),
|
|
48
|
+
friend,
|
|
49
|
+
...(channel ? { channel: channel } : {}),
|
|
50
|
+
issuedBy: `tool:${context.channel.channel}`,
|
|
51
|
+
});
|
|
52
|
+
const where = issued.channel ? `from their ${issued.channel} account` : "from the account they want to link";
|
|
53
|
+
return `link code for ${friend.name}: ${issued.code}\nTell them to send exactly this code to me ${where}, in a 1:1 chat, before ${issued.expiresAt}. It works once.`;
|
|
54
|
+
},
|
|
55
|
+
summaryKeys: ["channel"],
|
|
56
|
+
riskProfile: { mutates: "durable_state_write", risk: "high", reason: "issues a code that links a new account to a family friend" },
|
|
57
|
+
};
|
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.shepherdToolDefinitions = exports.shepherdSignalToolDefinition = exports.shepherdReadToolDefinition = exports.shepherdOverviewToolDefinition = void 0;
|
|
36
|
+
exports.shepherdToolDefinitions = exports.shepherdSendToolDefinition = exports.shepherdSignalToolDefinition = exports.shepherdReadToolDefinition = exports.shepherdOverviewToolDefinition = exports.SEND_KEYS = exports.SEND_TEXT_MAX_CHARS = void 0;
|
|
37
37
|
exports.shepherdToolsEnabled = shepherdToolsEnabled;
|
|
38
38
|
const fs = __importStar(require("node:fs"));
|
|
39
39
|
const path = __importStar(require("node:path"));
|
|
@@ -43,13 +43,19 @@ const runtime_1 = require("../nerves/runtime");
|
|
|
43
43
|
const host_1 = require("../senses/shepherd/host");
|
|
44
44
|
const redact_1 = require("../senses/shepherd/redact");
|
|
45
45
|
const returns_1 = require("../senses/shepherd/returns");
|
|
46
|
+
const tools_base_1 = require("./tools-base");
|
|
46
47
|
/**
|
|
47
48
|
* Shepherd's tools: every coding-agent session on this machine's terminal host with what Shepherd
|
|
48
|
-
* last decided about it (`shepherd_overview`), what a session shows (`shepherd_read`),
|
|
49
|
-
* line or notification for the human (`shepherd_signal`)
|
|
49
|
+
* last decided about it (`shepherd_overview`), what a session shows (`shepherd_read`), a status
|
|
50
|
+
* line or notification for the human (`shepherd_signal`), and a line or key typed for the human
|
|
51
|
+
* who asked for it (`shepherd_send`).
|
|
50
52
|
*/
|
|
51
53
|
const STATUS_MAX_CHARS = 120;
|
|
52
54
|
const READ_MAX_CHARS = 16_000;
|
|
55
|
+
exports.SEND_TEXT_MAX_CHARS = 2_000;
|
|
56
|
+
exports.SEND_KEYS = ["enter", "escape", "up", "down", "tab", "ctrl-c", "1", "2", "3", "4", "5", "6", "7", "8", "9", "y", "n"];
|
|
57
|
+
/** Keys that can approve or choose something on a prompt: pressed only on the screen the caller last read. */
|
|
58
|
+
const CHOOSING_KEYS = new Set(["enter", "tab", "1", "2", "3", "4", "5", "6", "7", "8", "9", "y", "n"]);
|
|
53
59
|
function text(value) {
|
|
54
60
|
return typeof value === "string" ? value : "";
|
|
55
61
|
}
|
|
@@ -118,7 +124,7 @@ exports.shepherdReadToolDefinition = {
|
|
|
118
124
|
type: "function",
|
|
119
125
|
function: {
|
|
120
126
|
name: "shepherd_read",
|
|
121
|
-
description: "Read the text of one terminal session (what a coding agent shows now and what it did recently). Secret-shaped strings are redacted and the text is bounded. Read-only. Take the session id or ref from shepherd_overview.",
|
|
127
|
+
description: "Read the text of one terminal session (what a coding agent shows now and what it did recently). Secret-shaped strings are redacted and the text is bounded. Read-only. Take the session id or ref from shepherd_overview. It also returns screen, a fingerprint of what the terminal shows now, for shepherd_send.",
|
|
122
128
|
parameters: {
|
|
123
129
|
type: "object",
|
|
124
130
|
properties: {
|
|
@@ -135,8 +141,11 @@ exports.shepherdReadToolDefinition = {
|
|
|
135
141
|
if (!session)
|
|
136
142
|
return Promise.resolve(JSON.stringify({ error: "name a session id or ref from shepherd_overview" }));
|
|
137
143
|
return withHost("shepherd_read", ctx, async (host) => {
|
|
138
|
-
const
|
|
139
|
-
|
|
144
|
+
const lines = boundedLines(args.lines);
|
|
145
|
+
// A fingerprint needs enough lines to match the one shepherd_send takes, whatever the caller asked to see.
|
|
146
|
+
const raw = await host.read(session, Math.max(lines, redact_1.FINGERPRINT_READ_LINES));
|
|
147
|
+
const screen = (0, redact_1.redactSecrets)(raw).replace(/\n$/, "").split("\n").slice(-lines).join("\n");
|
|
148
|
+
return { session, text: screen.slice(-READ_MAX_CHARS), truncated: screen.length > READ_MAX_CHARS, screen: (0, redact_1.screenFingerprint)(raw) };
|
|
140
149
|
});
|
|
141
150
|
},
|
|
142
151
|
summaryKeys: ["session"],
|
|
@@ -177,4 +186,77 @@ exports.shepherdSignalToolDefinition = {
|
|
|
177
186
|
summaryKeys: ["session", "status"],
|
|
178
187
|
riskProfile: { mutates: "external_side_effect", risk: "high", reason: "changes what the terminal host shows the human (a status line or a desktop notification)" },
|
|
179
188
|
};
|
|
180
|
-
|
|
189
|
+
function refused(reason, meta) {
|
|
190
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "repertoire", event: "repertoire.shepherd_send_refused", message: "refused to type into a terminal", meta: { ...meta, reason } });
|
|
191
|
+
return { error: reason };
|
|
192
|
+
}
|
|
193
|
+
exports.shepherdSendToolDefinition = {
|
|
194
|
+
tool: {
|
|
195
|
+
type: "function",
|
|
196
|
+
function: {
|
|
197
|
+
name: "shepherd_send",
|
|
198
|
+
description: `Type into one coding agent's terminal on this machine for the human you are talking with, because they asked you to. Use it only to deliver that human's own instruction or keypress ("tell it to run the tests", "approve that", "stop it"): never on your own initiative, and never for anyone else. Give exactly one of text or key. text is one line (at most ${exports.SEND_TEXT_MAX_CHARS} characters, no control characters), typed as "[Ouro for <their name>] <text>" and submitted with Enter. key is pressed alone: one of ${exports.SEND_KEYS.join(", ")} (a menu or permission prompt, escape to interrupt, ctrl-c to cancel). enter, tab, a digit, y and n can approve or choose on a prompt, so they need screen from a shepherd_read made just before, and nothing is sent if the terminal changed since; tell the human what a prompt asks before approving it unless they have seen it. Pass screen with text too when the text answers what is on screen. To interrupt and then instruct, send escape, read again, then send the text. It also types into the terminal the human has focused on this machine.`,
|
|
199
|
+
parameters: {
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: {
|
|
202
|
+
session: { type: "string", description: "Session id or ref (surface:12) from shepherd_overview." },
|
|
203
|
+
text: { type: "string", description: "The human's instruction, one line. Sent with the [Ouro for <name>] prefix and Enter." },
|
|
204
|
+
key: { type: "string", enum: [...exports.SEND_KEYS], description: "One key to press instead of text." },
|
|
205
|
+
screen: { type: "string", description: "The screen fingerprint from the shepherd_read just before. Required for enter, tab, a digit, y and n." },
|
|
206
|
+
},
|
|
207
|
+
required: ["session"],
|
|
208
|
+
additionalProperties: false,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
handler: (args, ctx) => {
|
|
213
|
+
const session = text(args.session).trim();
|
|
214
|
+
const line = text(args.text).trim();
|
|
215
|
+
const key = text(args.key).trim().toLowerCase();
|
|
216
|
+
const seen = text(args.screen).trim();
|
|
217
|
+
const friend = ctx?.context?.friend;
|
|
218
|
+
const meta = { session, chars: line.length, key: key || null, friendId: friend?.id ?? null };
|
|
219
|
+
const refuse = (reason) => Promise.resolve(JSON.stringify(refused(reason, meta)));
|
|
220
|
+
// The private runtime talks to itself as a family "friend"; only a real family member's request may type.
|
|
221
|
+
if (!friend || friend.trustLevel !== "family" || friend.id === tools_base_1.SELF_FRIEND_ID)
|
|
222
|
+
return refuse("shepherd_send types only for a family member who asked for it in this conversation");
|
|
223
|
+
if (!session)
|
|
224
|
+
return refuse("name a session id or ref from shepherd_overview");
|
|
225
|
+
if (!line === !key)
|
|
226
|
+
return refuse("give exactly one of text or key");
|
|
227
|
+
if (key && !exports.SEND_KEYS.includes(key))
|
|
228
|
+
return refuse(`key must be one of ${exports.SEND_KEYS.join(", ")}`);
|
|
229
|
+
if (line.length > exports.SEND_TEXT_MAX_CHARS)
|
|
230
|
+
return refuse(`text must be at most ${exports.SEND_TEXT_MAX_CHARS} characters`);
|
|
231
|
+
if (/[\p{Cc}\u2028\u2029]/u.test(line))
|
|
232
|
+
return refuse("text must be one line with no control characters; press enter, escape or ctrl-c with key");
|
|
233
|
+
if (CHOOSING_KEYS.has(key) && !seen)
|
|
234
|
+
return refuse(`${key} can approve or choose on a prompt, so pass screen from a shepherd_read made just before`);
|
|
235
|
+
return withHost("shepherd_send", ctx, async (host) => {
|
|
236
|
+
const target = (await host.list()).find((entry) => entry.id === session || entry.ref === session);
|
|
237
|
+
if (!target)
|
|
238
|
+
return refused(`session ${session} is gone; check shepherd_overview`, meta);
|
|
239
|
+
// Read again right before typing, as Shepherd does before its own answers: a gone or changed screen gets nothing.
|
|
240
|
+
const now = (0, redact_1.screenFingerprint)(await host.read(target.id, redact_1.FINGERPRINT_READ_LINES));
|
|
241
|
+
if (seen && seen !== now)
|
|
242
|
+
return refused("the terminal changed since it was read, so nothing was sent; read it again", meta);
|
|
243
|
+
const typed = key ? null : `[Ouro for ${friend.name}] ${line}`;
|
|
244
|
+
if (key)
|
|
245
|
+
await host.key(target.id, key);
|
|
246
|
+
else
|
|
247
|
+
await host.prompt(target.id, typed);
|
|
248
|
+
(0, runtime_1.emitNervesEvent)({
|
|
249
|
+
component: "repertoire",
|
|
250
|
+
event: "repertoire.shepherd_send",
|
|
251
|
+
message: "typed into a terminal for a family member",
|
|
252
|
+
meta: { ...meta, session: target.id, ref: target.ref, agent: target.agent, focused: target.focused, screenChecked: !!seen },
|
|
253
|
+
});
|
|
254
|
+
// The status line shows the human at the machine who typed; it is best effort, the send already happened.
|
|
255
|
+
await host.signal(target.id, `sent for ${friend.name}`.slice(0, STATUS_MAX_CHARS)).catch(() => undefined);
|
|
256
|
+
return { session: target.ref, sent: key ? { key } : { text: typed }, focused: target.focused };
|
|
257
|
+
});
|
|
258
|
+
},
|
|
259
|
+
summaryKeys: ["session", "key"],
|
|
260
|
+
riskProfile: { mutates: "external_side_effect", risk: "high", reason: "types into a coding agent's terminal for the human, which can answer or approve its prompts" },
|
|
261
|
+
};
|
|
262
|
+
exports.shepherdToolDefinitions = [exports.shepherdOverviewToolDefinition, exports.shepherdReadToolDefinition, exports.shepherdSignalToolDefinition, exports.shepherdSendToolDefinition];
|
package/dist/senses/pipeline.js
CHANGED
|
@@ -76,6 +76,7 @@ const flight_recorder_1 = require("../arc/flight-recorder");
|
|
|
76
76
|
const context_loss_sentinel_1 = require("../heart/context-loss-sentinel");
|
|
77
77
|
const cares_1 = require("../arc/cares");
|
|
78
78
|
const turn_execution_lease_1 = require("../heart/turn-execution-lease");
|
|
79
|
+
const friend_link_codes_1 = require("../mind/friend-link-codes");
|
|
79
80
|
const VOICE_PENDING_MAX_AGE_MS = 15 * 60 * 1_000;
|
|
80
81
|
const TELEGRAM_GENERIC_OUTREACH_PENDING_MAX_AGE_MS = 15 * 60 * 1_000;
|
|
81
82
|
function pendingExpirationReason(channel, message, now, agentName) {
|
|
@@ -637,16 +638,16 @@ async function handleInboundTurnExclusive(input) {
|
|
|
637
638
|
}
|
|
638
639
|
}
|
|
639
640
|
}
|
|
641
|
+
const firstUserMsg = input.messages.find((m) => m.role === "user");
|
|
642
|
+
const userText = firstUserMsg
|
|
643
|
+
? (typeof firstUserMsg.content === "string"
|
|
644
|
+
? firstUserMsg.content
|
|
645
|
+
: Array.isArray(firstUserMsg.content)
|
|
646
|
+
? firstUserMsg.content.find((p) => p.type === "text")?.text ?? ""
|
|
647
|
+
: /* v8 ignore next -- defensive: content is always string or array @preserve */ "")
|
|
648
|
+
: "";
|
|
640
649
|
// Step 0b: Slash command interception (before friend resolution / agent turn)
|
|
641
650
|
{
|
|
642
|
-
const firstUserMsg = input.messages.find((m) => m.role === "user");
|
|
643
|
-
const userText = firstUserMsg
|
|
644
|
-
? (typeof firstUserMsg.content === "string"
|
|
645
|
-
? firstUserMsg.content
|
|
646
|
-
: Array.isArray(firstUserMsg.content)
|
|
647
|
-
? firstUserMsg.content.find((p) => p.type === "text")?.text ?? ""
|
|
648
|
-
: /* v8 ignore next -- defensive: content is always string or array @preserve */ "")
|
|
649
|
-
: "";
|
|
650
651
|
const parsed = (0, commands_1.parseSlashCommand)(userText);
|
|
651
652
|
if (parsed) {
|
|
652
653
|
const registry = (0, commands_1.getSharedCommandRegistry)();
|
|
@@ -684,6 +685,27 @@ async function handleInboundTurnExclusive(input) {
|
|
|
684
685
|
senseType: input.capabilities.senseType,
|
|
685
686
|
},
|
|
686
687
|
});
|
|
688
|
+
// Step 1b: Identity link code. Runs before the trust gate so an identity the agent
|
|
689
|
+
// does not trust yet can prove it belongs to someone it does. Never a model turn.
|
|
690
|
+
if (input.channel !== "inner" && (0, friend_link_codes_1.parseLinkCode)(userText)) {
|
|
691
|
+
const agentRoot = (0, identity_1.getAgentRoot)();
|
|
692
|
+
const redemption = (await (0, friend_link_codes_1.redeemLinkCode)({
|
|
693
|
+
agentRoot,
|
|
694
|
+
friendsDir: path.join(agentRoot, "friends"),
|
|
695
|
+
store: input.friendStore,
|
|
696
|
+
text: userText,
|
|
697
|
+
claimant: resolvedContext.friend,
|
|
698
|
+
channel: input.channel,
|
|
699
|
+
isGroupChat: input.isGroupChat ?? false,
|
|
700
|
+
}));
|
|
701
|
+
input.callbacks.onTextChunk(redemption.reply);
|
|
702
|
+
return {
|
|
703
|
+
resolvedContext: redemption.status === "linked" ? { ...resolvedContext, friend: redemption.friend } : resolvedContext,
|
|
704
|
+
gateResult: { allowed: true },
|
|
705
|
+
turnOutcome: "command",
|
|
706
|
+
commandAction: "response",
|
|
707
|
+
};
|
|
708
|
+
}
|
|
687
709
|
// Step 2: Trust gate
|
|
688
710
|
const gateInput = {
|
|
689
711
|
friend: resolvedContext.friend,
|
|
@@ -70,7 +70,8 @@ const STOP_HOOKS = new Set(["Stop", "StopFailure"]);
|
|
|
70
70
|
const IDLE_HOOKS = new Set([...STOP_HOOKS, "SessionStart"]);
|
|
71
71
|
/** Hooks that say nothing about whether the main agent is working. */
|
|
72
72
|
const NEUTRAL_HOOKS = new Set(["SubagentStop", "Notification"]);
|
|
73
|
-
|
|
73
|
+
/** Keys cmux presses with `surface.send_key`, by its own names; every other key is typed as its character. */
|
|
74
|
+
const NAMED_KEYS = { enter: "enter", escape: "escape", up: "up", down: "down", tab: "tab", "ctrl-c": "ctrl+c" };
|
|
74
75
|
const SUBMIT_DELAY_MS = 300;
|
|
75
76
|
/** A timer that can be cancelled; the sense and the host take it as an option so tests can drive time. */
|
|
76
77
|
function realSchedule(fn, ms) {
|
|
@@ -167,8 +168,9 @@ function createCmuxHost(client, options = {}) {
|
|
|
167
168
|
async key(sessionId, key) {
|
|
168
169
|
const surface = await locate(sessionId);
|
|
169
170
|
const target = { workspace_id: surface.workspaceId, surface_id: surface.id };
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
const named = NAMED_KEYS[key];
|
|
172
|
+
if (named)
|
|
173
|
+
await client.call("surface.send_key", { ...target, key: named });
|
|
172
174
|
else
|
|
173
175
|
await client.call("surface.send_text", { ...target, text: key });
|
|
174
176
|
},
|