@ouro.bot/cli 0.1.0-alpha.917 → 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 +14 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/config-registry.js +2 -2
- 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 +19 -3
- package/dist/heart/model-capabilities.js +72 -16
- package/dist/heart/provider-models.js +3 -3
- package/dist/heart/providers/github-copilot.js +4 -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,209 @@
|
|
|
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.DEFAULT_LINK_CODE_TTL_MS = void 0;
|
|
37
|
+
exports.parseLinkCode = parseLinkCode;
|
|
38
|
+
exports.formatLinkCode = formatLinkCode;
|
|
39
|
+
exports.issueLinkCode = issueLinkCode;
|
|
40
|
+
exports.redeemLinkCode = redeemLinkCode;
|
|
41
|
+
// Identity link codes: how a person proves that a new channel identity is theirs.
|
|
42
|
+
//
|
|
43
|
+
// A friend on a surface the agent already trusts (the owner's CLI, or a family chat)
|
|
44
|
+
// asks for a short code. When that exact code arrives as a 1:1 message from an
|
|
45
|
+
// identity the agent does not yet trust, that identity's record merges into the
|
|
46
|
+
// friend who asked, and the new identity is treated as them from then on.
|
|
47
|
+
//
|
|
48
|
+
// Codes are single use and expire. Only a SHA-256 digest of each code is stored, under
|
|
49
|
+
// the agent's `state/` directory, which bundle sync does not carry. A code is consumed
|
|
50
|
+
// by its first presentation, whatever the outcome, by an atomic rename, so a replay or a
|
|
51
|
+
// race between two senders finds nothing to redeem.
|
|
52
|
+
const fs = __importStar(require("node:fs"));
|
|
53
|
+
const path = __importStar(require("node:path"));
|
|
54
|
+
const node_crypto_1 = require("node:crypto");
|
|
55
|
+
const runtime_1 = require("../nerves/runtime");
|
|
56
|
+
const friend_merge_1 = require("./friend-merge");
|
|
57
|
+
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
58
|
+
const CODE_BODY_LENGTH = 8;
|
|
59
|
+
exports.DEFAULT_LINK_CODE_TTL_MS = 10 * 60 * 1000;
|
|
60
|
+
const MAX_LINK_CODE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
61
|
+
const CODE_SHAPE = new RegExp(`^OURO-?([${CODE_ALPHABET}]{4})-?([${CODE_ALPHABET}]{4})$`);
|
|
62
|
+
function codesRoot(agentRoot) {
|
|
63
|
+
return path.join(agentRoot, "state", "friend-link-codes");
|
|
64
|
+
}
|
|
65
|
+
function digest(codeBody) {
|
|
66
|
+
return (0, node_crypto_1.createHash)("sha256").update(codeBody).digest("hex");
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The code body when `text` is exactly one link code (case, spacing, dashes and simple
|
|
70
|
+
* markup ignored), otherwise null. Anything else in the message means it is not a code.
|
|
71
|
+
*/
|
|
72
|
+
function parseLinkCode(text) {
|
|
73
|
+
const plain = text
|
|
74
|
+
.replace(/<at>[^<]*<\/at>/gi, " ")
|
|
75
|
+
.replace(/<[^>]*>/g, " ")
|
|
76
|
+
.replace(/ /g, " ")
|
|
77
|
+
.replace(/\s+/g, "")
|
|
78
|
+
.toUpperCase();
|
|
79
|
+
const match = CODE_SHAPE.exec(plain);
|
|
80
|
+
return match ? `${match[1]}${match[2]}` : null;
|
|
81
|
+
}
|
|
82
|
+
function formatLinkCode(codeBody) {
|
|
83
|
+
return `OURO-${codeBody.slice(0, 4)}-${codeBody.slice(4)}`;
|
|
84
|
+
}
|
|
85
|
+
function issueLinkCode(input) {
|
|
86
|
+
const now = (input.now ?? (() => new Date()))();
|
|
87
|
+
const ttlMs = Math.min(Math.max(input.ttlMs ?? exports.DEFAULT_LINK_CODE_TTL_MS, 60 * 1000), MAX_LINK_CODE_TTL_MS);
|
|
88
|
+
let codeBody = "";
|
|
89
|
+
for (let index = 0; index < CODE_BODY_LENGTH; index += 1)
|
|
90
|
+
codeBody += CODE_ALPHABET[(0, node_crypto_1.randomInt)(CODE_ALPHABET.length)];
|
|
91
|
+
const record = {
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
friendId: input.friend.id,
|
|
94
|
+
friendName: input.friend.name,
|
|
95
|
+
...(input.channel ? { channel: input.channel } : {}),
|
|
96
|
+
issuedAt: now.toISOString(),
|
|
97
|
+
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
98
|
+
issuedBy: input.issuedBy,
|
|
99
|
+
};
|
|
100
|
+
const pendingDir = path.join(codesRoot(input.agentRoot), "pending");
|
|
101
|
+
fs.mkdirSync(pendingDir, { recursive: true, mode: 0o700 });
|
|
102
|
+
fs.writeFileSync(path.join(pendingDir, `${digest(codeBody)}.json`), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
103
|
+
(0, runtime_1.emitNervesEvent)({
|
|
104
|
+
component: "friends",
|
|
105
|
+
event: "friends.link_code_issued",
|
|
106
|
+
message: "issued an identity link code",
|
|
107
|
+
meta: { friendId: input.friend.id, channel: input.channel ?? "any", expiresAt: record.expiresAt, issuedBy: input.issuedBy },
|
|
108
|
+
});
|
|
109
|
+
return { code: formatLinkCode(codeBody), friendId: record.friendId, friendName: record.friendName, ...(record.channel ? { channel: record.channel } : {}), expiresAt: record.expiresAt };
|
|
110
|
+
}
|
|
111
|
+
const REJECTION_REPLIES = {
|
|
112
|
+
unknown_or_used: "That link code isn't valid. Ask for a new one.",
|
|
113
|
+
expired: "That link code has expired. Ask for a new one.",
|
|
114
|
+
group_chat: "Link codes only work in a 1:1 chat with me. Ask for a new code and send it to me directly.",
|
|
115
|
+
wrong_channel: "That link code was issued for a different channel. Ask for a new one.",
|
|
116
|
+
already_linked: "This account is already linked to you.",
|
|
117
|
+
claimant_trusted: "This account is already known to me as someone I trust, so a code can't relink it. The owner can merge the records instead.",
|
|
118
|
+
friend_missing: "The person this code was issued for no longer exists. Ask for a new code.",
|
|
119
|
+
};
|
|
120
|
+
function reject(reason, meta) {
|
|
121
|
+
(0, runtime_1.emitNervesEvent)({
|
|
122
|
+
level: "warn",
|
|
123
|
+
component: "friends",
|
|
124
|
+
event: "friends.link_code_rejected",
|
|
125
|
+
message: "rejected an identity link code",
|
|
126
|
+
meta: { reason, ...meta },
|
|
127
|
+
});
|
|
128
|
+
return { status: "rejected", reason, reply: REJECTION_REPLIES[reason] };
|
|
129
|
+
}
|
|
130
|
+
/** Atomically take the code out of `pending/`. Null when no pending code has this body. */
|
|
131
|
+
function consumeCode(agentRoot, codeBody, nowIso) {
|
|
132
|
+
const root = codesRoot(agentRoot);
|
|
133
|
+
const name = `${digest(codeBody)}.json`;
|
|
134
|
+
const usedDir = path.join(root, "used");
|
|
135
|
+
fs.mkdirSync(usedDir, { recursive: true, mode: 0o700 });
|
|
136
|
+
const usedPath = path.join(usedDir, name);
|
|
137
|
+
try {
|
|
138
|
+
fs.renameSync(path.join(root, "pending", name), usedPath);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
/* v8 ignore next -- only ENOENT is expected; other filesystem failures propagate @preserve */
|
|
142
|
+
if (error.code !== "ENOENT")
|
|
143
|
+
throw error;
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const record = JSON.parse(fs.readFileSync(usedPath, "utf-8"));
|
|
147
|
+
fs.writeFileSync(usedPath, `${JSON.stringify({ ...record, consumedAt: nowIso }, null, 2)}\n`, { mode: 0o600 });
|
|
148
|
+
return { record, usedPath };
|
|
149
|
+
}
|
|
150
|
+
function recordOutcome(usedPath, outcome) {
|
|
151
|
+
const current = JSON.parse(fs.readFileSync(usedPath, "utf-8"));
|
|
152
|
+
fs.writeFileSync(usedPath, `${JSON.stringify({ ...current, ...outcome }, null, 2)}\n`, { mode: 0o600 });
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Redeem a link code sent by `claimant`. Returns null when `text` is not a link code, so
|
|
156
|
+
* callers can run this on every inbound message and fall through to a normal turn.
|
|
157
|
+
*/
|
|
158
|
+
async function redeemLinkCode(input) {
|
|
159
|
+
const codeBody = parseLinkCode(input.text);
|
|
160
|
+
if (!codeBody)
|
|
161
|
+
return null;
|
|
162
|
+
const now = (input.now ?? (() => new Date()))();
|
|
163
|
+
const nowIso = now.toISOString();
|
|
164
|
+
const baseMeta = { claimantId: input.claimant.id, channel: input.channel };
|
|
165
|
+
const consumed = consumeCode(input.agentRoot, codeBody, nowIso);
|
|
166
|
+
if (!consumed)
|
|
167
|
+
return reject("unknown_or_used", baseMeta);
|
|
168
|
+
const { record, usedPath } = consumed;
|
|
169
|
+
const meta = { ...baseMeta, friendId: record.friendId };
|
|
170
|
+
const refuse = (reason) => {
|
|
171
|
+
recordOutcome(usedPath, { outcome: "rejected", reason, claimantId: input.claimant.id, channel: input.channel });
|
|
172
|
+
return reject(reason, meta);
|
|
173
|
+
};
|
|
174
|
+
if (Date.parse(record.expiresAt) <= now.getTime())
|
|
175
|
+
return refuse("expired");
|
|
176
|
+
if (input.isGroupChat)
|
|
177
|
+
return refuse("group_chat");
|
|
178
|
+
if (record.channel && record.channel !== input.channel)
|
|
179
|
+
return refuse("wrong_channel");
|
|
180
|
+
if (input.claimant.id === record.friendId)
|
|
181
|
+
return refuse("already_linked");
|
|
182
|
+
// A code can only bring in an identity the agent does not already trust; trusted
|
|
183
|
+
// records are merged deliberately by the owner, not by whoever holds a code.
|
|
184
|
+
if (input.claimant.trustLevel !== "stranger" && input.claimant.trustLevel !== "acquaintance")
|
|
185
|
+
return refuse("claimant_trusted");
|
|
186
|
+
const merged = await (0, friend_merge_1.mergeFriends)({
|
|
187
|
+
store: input.store,
|
|
188
|
+
friendsDir: input.friendsDir,
|
|
189
|
+
targetId: record.friendId,
|
|
190
|
+
sourceIds: [input.claimant.id],
|
|
191
|
+
actor: `link-code:${record.issuedBy}`,
|
|
192
|
+
reason: `identity link code redeemed on ${input.channel}`,
|
|
193
|
+
now: () => now,
|
|
194
|
+
});
|
|
195
|
+
if (!merged.ok)
|
|
196
|
+
return refuse("friend_missing");
|
|
197
|
+
recordOutcome(usedPath, { outcome: "linked", claimantId: input.claimant.id, channel: input.channel, linkedExternalIds: merged.addedExternalIds.map((externalId) => externalId.provider) });
|
|
198
|
+
(0, runtime_1.emitNervesEvent)({
|
|
199
|
+
component: "friends",
|
|
200
|
+
event: "friends.link_code_redeemed",
|
|
201
|
+
message: "linked a new identity through a link code",
|
|
202
|
+
meta: { ...meta, trustLevel: merged.target.trustLevel, linkedExternalIds: merged.addedExternalIds.map((externalId) => externalId.provider) },
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
status: "linked",
|
|
206
|
+
friend: merged.target,
|
|
207
|
+
reply: `Linked. I'll recognize this ${input.channel} account as ${merged.target.name} from now on.`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -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
|
+
};
|