@ouro.bot/friends 0.1.0-alpha.4
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/LICENSE +201 -0
- package/README.md +514 -0
- package/changelog.json +34 -0
- package/dist/a2a/index.d.ts +102 -0
- package/dist/a2a/index.js +198 -0
- package/dist/agent-peer.d.ts +17 -0
- package/dist/agent-peer.js +57 -0
- package/dist/channel.d.ts +11 -0
- package/dist/channel.js +132 -0
- package/dist/consent.d.ts +34 -0
- package/dist/consent.js +62 -0
- package/dist/coordination.d.ts +100 -0
- package/dist/coordination.js +255 -0
- package/dist/file-bundle.d.ts +12 -0
- package/dist/file-bundle.js +23 -0
- package/dist/grant-store-file.d.ts +16 -0
- package/dist/grant-store-file.js +136 -0
- package/dist/grant-store.d.ts +7 -0
- package/dist/grant-store.js +8 -0
- package/dist/grants.d.ts +39 -0
- package/dist/grants.js +84 -0
- package/dist/group-context.d.ts +21 -0
- package/dist/group-context.js +144 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +105 -0
- package/dist/link-identity.d.ts +14 -0
- package/dist/link-identity.js +88 -0
- package/dist/mcp/bin.d.ts +2 -0
- package/dist/mcp/bin.js +16 -0
- package/dist/mcp/dispatch.d.ts +14 -0
- package/dist/mcp/dispatch.js +432 -0
- package/dist/mcp/index.d.ts +6 -0
- package/dist/mcp/index.js +14 -0
- package/dist/mcp/run-main.d.ts +7 -0
- package/dist/mcp/run-main.js +45 -0
- package/dist/mcp/schemas.d.ts +10 -0
- package/dist/mcp/schemas.js +398 -0
- package/dist/mcp/server.d.ts +21 -0
- package/dist/mcp/server.js +194 -0
- package/dist/mission-share.d.ts +94 -0
- package/dist/mission-share.js +232 -0
- package/dist/mission-store-file.d.ts +18 -0
- package/dist/mission-store-file.js +153 -0
- package/dist/mission-store.d.ts +10 -0
- package/dist/mission-store.js +9 -0
- package/dist/missions.d.ts +31 -0
- package/dist/missions.js +98 -0
- package/dist/notes.d.ts +11 -0
- package/dist/notes.js +90 -0
- package/dist/observability.d.ts +27 -0
- package/dist/observability.js +31 -0
- package/dist/outcomes.d.ts +9 -0
- package/dist/outcomes.js +51 -0
- package/dist/resolver.d.ts +28 -0
- package/dist/resolver.js +187 -0
- package/dist/results.d.ts +8 -0
- package/dist/results.js +2 -0
- package/dist/room.d.ts +22 -0
- package/dist/room.js +40 -0
- package/dist/share.d.ts +106 -0
- package/dist/share.js +223 -0
- package/dist/standing.d.ts +83 -0
- package/dist/standing.js +111 -0
- package/dist/store-file.d.ts +21 -0
- package/dist/store-file.js +264 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +4 -0
- package/dist/tokens.d.ts +8 -0
- package/dist/tokens.js +26 -0
- package/dist/trust-explanation.d.ts +16 -0
- package/dist/trust-explanation.js +74 -0
- package/dist/trust-mutation.d.ts +4 -0
- package/dist/trust-mutation.js +29 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.js +51 -0
- package/dist/util/cap-string.d.ts +7 -0
- package/dist/util/cap-string.js +35 -0
- package/dist/verifier.d.ts +11 -0
- package/dist/verifier.js +29 -0
- package/dist/whoami.d.ts +7 -0
- package/dist/whoami.js +39 -0
- package/package.json +68 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { MissionStore } from "./mission-store";
|
|
2
|
+
import type { FriendStore } from "./store";
|
|
3
|
+
import type { GrantStore } from "./grant-store";
|
|
4
|
+
import type { AgentAttribution, MissionRecord, RelationshipOutcome, TrustLevel } from "./types";
|
|
5
|
+
import type { ConsentPolicy } from "./consent";
|
|
6
|
+
import type { AgentVerifier } from "./verifier";
|
|
7
|
+
/** A learning as carried on the wire: its value plus who FIRST asserted it
|
|
8
|
+
* (`originallyAssertedBy`), so the consumer can attribute it without laundering
|
|
9
|
+
* an imported fact into first-party. The mission analogue of `SharedNote`. */
|
|
10
|
+
export interface SharedLearning {
|
|
11
|
+
key: string;
|
|
12
|
+
value: string;
|
|
13
|
+
originallyAssertedBy?: AgentAttribution;
|
|
14
|
+
}
|
|
15
|
+
/** The cross-agent mission-share envelope. Names the subject by JOIN KEY
|
|
16
|
+
* (`missionKey`) + title only — NEVER a local UUID. A SIBLING of
|
|
17
|
+
* `ProfileShareEnvelope` (Fork A), not a widening. */
|
|
18
|
+
export interface MissionShareEnvelope {
|
|
19
|
+
/** The mission, named by its join key — `missionKey` + a human title. */
|
|
20
|
+
subject: {
|
|
21
|
+
missionKey: string;
|
|
22
|
+
title: string;
|
|
23
|
+
};
|
|
24
|
+
/** The agent that produced this envelope (its join-key agentId). */
|
|
25
|
+
fromAgentId: string;
|
|
26
|
+
scope: "mission" | "outcomes";
|
|
27
|
+
/** Scope-filtered relationship outcomes (present for the `outcomes` scope). */
|
|
28
|
+
outcomes?: RelationshipOutcome[];
|
|
29
|
+
/** Scope-filtered shareable learnings (present for the `mission` scope). */
|
|
30
|
+
learnings?: SharedLearning[];
|
|
31
|
+
/** Opaque, verifier-specific proof slot. The TOFU verifier ignores it. */
|
|
32
|
+
proof?: string;
|
|
33
|
+
issuedAt: string;
|
|
34
|
+
}
|
|
35
|
+
export interface PrepareMissionShareInput {
|
|
36
|
+
/** The LOCAL mission to share, by its local UUID id (resolved via the store). */
|
|
37
|
+
missionId: string;
|
|
38
|
+
/** The recipient agent's join-key agentId. */
|
|
39
|
+
toAgentId: string;
|
|
40
|
+
scope: "mission" | "outcomes";
|
|
41
|
+
/** This agent's own join-key agentId — the original asserter of first-party
|
|
42
|
+
* learnings (so a shared first-party learning is attributed to self). */
|
|
43
|
+
selfAgentId: string;
|
|
44
|
+
/** Optional proof to stamp on the envelope (for a non-TOFU recipient verifier). */
|
|
45
|
+
proof?: string;
|
|
46
|
+
}
|
|
47
|
+
export type PrepareMissionShareStatus = "not_found" | "no_consent" | "no_recipient";
|
|
48
|
+
export type PrepareMissionShareResult = {
|
|
49
|
+
ok: true;
|
|
50
|
+
envelope: MissionShareEnvelope;
|
|
51
|
+
} | {
|
|
52
|
+
ok: false;
|
|
53
|
+
status: PrepareMissionShareStatus;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Producer half of the mission ledger. Consent-gated (subject = the mission's
|
|
57
|
+
* `missionKey`), scope-filtered, provenance-preserving. Names the mission by its
|
|
58
|
+
* join key, never the local UUID. The recipient's trust — read off this agent's
|
|
59
|
+
* own friend record for `toAgentId` — is the authorization input the policy uses.
|
|
60
|
+
*/
|
|
61
|
+
export declare function prepareMissionShare(missions: MissionStore, store: FriendStore, grants: GrantStore, input: PrepareMissionShareInput, consent?: ConsentPolicy): Promise<PrepareMissionShareResult>;
|
|
62
|
+
export interface ImportMissionShareInput {
|
|
63
|
+
envelope: MissionShareEnvelope;
|
|
64
|
+
/** The agent the envelope arrived from (its join-key agentId). */
|
|
65
|
+
fromAgentId: string;
|
|
66
|
+
/** This agent's resolved trust in the source agent — the cap on acceptance. */
|
|
67
|
+
trustOfSource: TrustLevel;
|
|
68
|
+
}
|
|
69
|
+
export type ImportMissionShareStatus = "imported" | "seeded" | "no_mission" | "untrusted_source" | "untrusted_introduction";
|
|
70
|
+
export type ImportMissionShareResult = {
|
|
71
|
+
ok: true;
|
|
72
|
+
status: "imported" | "seeded";
|
|
73
|
+
record: MissionRecord;
|
|
74
|
+
} | {
|
|
75
|
+
ok: false;
|
|
76
|
+
status: "no_mission" | "untrusted_source" | "untrusted_introduction";
|
|
77
|
+
};
|
|
78
|
+
export interface ImportMissionShareOptions {
|
|
79
|
+
/** Authentication seam. Defaults to TOFU. Authorization (trust) is still
|
|
80
|
+
* applied regardless of what the verifier says. */
|
|
81
|
+
verifier?: AgentVerifier;
|
|
82
|
+
/** Minimum trust a source must hold for its facts to be accepted at all.
|
|
83
|
+
* Default `acquaintance`: a stranger source is refused. */
|
|
84
|
+
minTrustToAccept?: TrustLevel;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Consumer half of the mission ledger — the non-clobbering merge. Resolves the
|
|
88
|
+
* mission by `findByMissionKey`; lands imported learnings in the
|
|
89
|
+
* `importedLearnings` namespace WITHOUT touching first-party `learnings`;
|
|
90
|
+
* append-merges + dedupes imported outcomes; NEVER recomputes status /
|
|
91
|
+
* participants; the source agent's trust caps acceptance; seeds an unknown
|
|
92
|
+
* mission only when a friend/family peer introduces it.
|
|
93
|
+
*/
|
|
94
|
+
export declare function importMissionShare(missions: MissionStore, input: ImportMissionShareInput, options?: ImportMissionShareOptions): Promise<ImportMissionShareResult>;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.prepareMissionShare = prepareMissionShare;
|
|
4
|
+
exports.importMissionShare = importMissionShare;
|
|
5
|
+
// prepareMissionShare (producer) + importMissionShare (consumer) — the shared
|
|
6
|
+
// mission ledger (brick 3). Structural twins of share.ts's profile producer /
|
|
7
|
+
// consumer, re-aimed from a PERSON at a MISSION.
|
|
8
|
+
//
|
|
9
|
+
// Two DIFFERENT agents agreeing they did the SAME mission (by `missionKey`) AND
|
|
10
|
+
// sharing what they collectively learned — WITH CONSENT, without first-party
|
|
11
|
+
// learnings being clobbered. Store-only + transport-agnostic: prepareMissionShare
|
|
12
|
+
// returns an envelope, importMissionShare consumes one; the WIRE is the caller's
|
|
13
|
+
// job. Pure — the only node builtin is `node:crypto`, mirroring share.ts.
|
|
14
|
+
//
|
|
15
|
+
// Through-line invariants (every one is tested in mission-share.test.ts):
|
|
16
|
+
// - the mission is named by its JOIN KEY (`missionKey`), NEVER the local UUID;
|
|
17
|
+
// - the share is consent-gated (subject = the missionKey) and scope-filtered;
|
|
18
|
+
// - imported learnings NEVER touch first-party `learnings` (a separate
|
|
19
|
+
// `importedLearnings` namespace) — first-party always wins, structurally;
|
|
20
|
+
// - the source agent's trust CAPS acceptance (a stranger peer is refused);
|
|
21
|
+
// - `status` / `participants` are NEVER recomputed from an import (non-transitive);
|
|
22
|
+
// - imported outcomes are append-merged, stamped `origin:imported`, and deduped
|
|
23
|
+
// by (missionId, timestamp, assertedBy.agentId) — same peer idempotent,
|
|
24
|
+
// different peers coexist;
|
|
25
|
+
// - an unknown mission may be SEEDED only by a friend/family introducing peer.
|
|
26
|
+
const node_crypto_1 = require("node:crypto");
|
|
27
|
+
const observability_1 = require("./observability");
|
|
28
|
+
const consent_1 = require("./consent");
|
|
29
|
+
const verifier_1 = require("./verifier");
|
|
30
|
+
const share_1 = require("./share");
|
|
31
|
+
/** Build the scope-filtered shared learnings: only `shareable` learnings, each
|
|
32
|
+
* attributed to its original asserter (self for a first-party learning, the
|
|
33
|
+
* recorded original asserter for a relayed import). Reuses `originalAsserterOf`
|
|
34
|
+
* from share.ts — single-sourced, no duplicate coverage surface. */
|
|
35
|
+
function buildSharedLearnings(record, selfAgentId) {
|
|
36
|
+
return Object.entries(record.learnings)
|
|
37
|
+
.filter(([, learning]) => learning.shareable === true)
|
|
38
|
+
.map(([key, learning]) => ({
|
|
39
|
+
key,
|
|
40
|
+
value: learning.value,
|
|
41
|
+
originallyAssertedBy: (0, share_1.originalAsserterOf)(learning, selfAgentId),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Producer half of the mission ledger. Consent-gated (subject = the mission's
|
|
46
|
+
* `missionKey`), scope-filtered, provenance-preserving. Names the mission by its
|
|
47
|
+
* join key, never the local UUID. The recipient's trust — read off this agent's
|
|
48
|
+
* own friend record for `toAgentId` — is the authorization input the policy uses.
|
|
49
|
+
*/
|
|
50
|
+
async function prepareMissionShare(missions, store, grants, input, consent = consent_1.DEFAULT_CONSENT_POLICY) {
|
|
51
|
+
const record = await missions.get(input.missionId);
|
|
52
|
+
if (!record) {
|
|
53
|
+
return { ok: false, status: "not_found" };
|
|
54
|
+
}
|
|
55
|
+
// The recipient's trust is read from this agent's own knowledge of it (the
|
|
56
|
+
// a2a-agent friend record for toAgentId). An unknown recipient defaults to
|
|
57
|
+
// stranger — never trusted by default.
|
|
58
|
+
const recipientRecord = await store.findByExternalId("a2a-agent", input.toAgentId);
|
|
59
|
+
const recipientTrust = recipientRecord?.trustLevel ?? "stranger";
|
|
60
|
+
const recipient = { agentId: input.toAgentId, trustLevel: recipientTrust };
|
|
61
|
+
// The subject is the mission, keyed by its missionKey (a mission is just
|
|
62
|
+
// another grant subject under the Fork-D opaque subject key).
|
|
63
|
+
const consented = await consent.consents({
|
|
64
|
+
subjectKey: record.missionKey,
|
|
65
|
+
recipient,
|
|
66
|
+
scope: input.scope,
|
|
67
|
+
grants,
|
|
68
|
+
});
|
|
69
|
+
if (!consented) {
|
|
70
|
+
return { ok: false, status: "no_consent" };
|
|
71
|
+
}
|
|
72
|
+
const now = new Date().toISOString();
|
|
73
|
+
const envelope = {
|
|
74
|
+
subject: { missionKey: record.missionKey, title: record.title },
|
|
75
|
+
fromAgentId: input.selfAgentId,
|
|
76
|
+
scope: input.scope,
|
|
77
|
+
issuedAt: now,
|
|
78
|
+
...(input.proof !== undefined ? { proof: input.proof } : {}),
|
|
79
|
+
...(input.scope === "outcomes"
|
|
80
|
+
? { outcomes: record.outcomes }
|
|
81
|
+
: { learnings: buildSharedLearnings(record, input.selfAgentId) }),
|
|
82
|
+
};
|
|
83
|
+
(0, observability_1.emitNervesEvent)({
|
|
84
|
+
component: "friends",
|
|
85
|
+
event: "friends.mission_share_prepared",
|
|
86
|
+
message: "prepared mission share envelope",
|
|
87
|
+
meta: { scope: input.scope, toAgentId: input.toAgentId, consentPolicy: consent.name },
|
|
88
|
+
});
|
|
89
|
+
return { ok: true, envelope };
|
|
90
|
+
}
|
|
91
|
+
// ── Consumer ──
|
|
92
|
+
/** Trust levels a peer must hold to INTRODUCE a previously-unknown mission. A
|
|
93
|
+
* friend/family peer may seed a new mission; a stranger / acquaintance peer may
|
|
94
|
+
* not (mirrors the person path's SEEDING_TRUST). */
|
|
95
|
+
const SEEDING_TRUST = new Set(["family", "friend"]);
|
|
96
|
+
const TRUST_RANK = { family: 4, friend: 3, acquaintance: 2, stranger: 1 };
|
|
97
|
+
function importedLearningFrom(learning, fromAgentId, now) {
|
|
98
|
+
return {
|
|
99
|
+
value: learning.value,
|
|
100
|
+
importedAt: now,
|
|
101
|
+
assertedBy: { agentId: fromAgentId },
|
|
102
|
+
...(learning.originallyAssertedBy ? { originallyAssertedBy: learning.originallyAssertedBy } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Merge the envelope's learnings into the record's `importedLearnings` namespace
|
|
106
|
+
* under the source agentId. First-party `learnings` are NOT passed in and are
|
|
107
|
+
* physically untouched. Within the namespace, the newest import wins per key. */
|
|
108
|
+
function mergeImportedLearnings(record, learnings, fromAgentId, now) {
|
|
109
|
+
const existing = record.importedLearnings ?? {};
|
|
110
|
+
const forAgent = { ...(existing[fromAgentId] ?? {}) };
|
|
111
|
+
for (const learning of learnings) {
|
|
112
|
+
forAgent[learning.key] = importedLearningFrom(learning, fromAgentId, now);
|
|
113
|
+
}
|
|
114
|
+
return { ...existing, [fromAgentId]: forAgent };
|
|
115
|
+
}
|
|
116
|
+
/** Append the envelope's outcomes to the record's outcomes, stamping each with
|
|
117
|
+
* `origin:imported` + the source attribution + importedAt, and DEDUPING by
|
|
118
|
+
* (missionId, timestamp, assertedBy.agentId): a row whose identity already exists
|
|
119
|
+
* is skipped (same peer idempotent); a row from a different peer with the same
|
|
120
|
+
* (missionId, timestamp) coexists. The existing rows' `assertedBy?.agentId` is
|
|
121
|
+
* read defensively — a first-party outcome may carry no provenance, and must
|
|
122
|
+
* never be spuriously matched. This outcome-merge is genuinely NEW logic (the
|
|
123
|
+
* person path's import never merged outcomes). */
|
|
124
|
+
function mergeImportedOutcomes(existing, incoming, fromAgentId, now) {
|
|
125
|
+
const identityOf = (o, assertedAgentId) => JSON.stringify([o.missionId, o.timestamp, assertedAgentId]);
|
|
126
|
+
const seen = new Set(existing.map((o) => identityOf(o, o.provenance?.assertedBy?.agentId)));
|
|
127
|
+
const merged = [...existing];
|
|
128
|
+
for (const o of incoming) {
|
|
129
|
+
const identity = identityOf(o, fromAgentId);
|
|
130
|
+
if (seen.has(identity))
|
|
131
|
+
continue;
|
|
132
|
+
seen.add(identity);
|
|
133
|
+
merged.push({
|
|
134
|
+
missionId: o.missionId,
|
|
135
|
+
result: o.result,
|
|
136
|
+
timestamp: o.timestamp,
|
|
137
|
+
...(o.note ? { note: o.note } : {}),
|
|
138
|
+
provenance: { origin: "imported", assertedBy: { agentId: fromAgentId }, importedAt: now },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return merged;
|
|
142
|
+
}
|
|
143
|
+
/** Create a freshly-seeded mission for a previously-unknown key. Always
|
|
144
|
+
* `status:"active"`, empty first-party `learnings`, carrying the subject's join
|
|
145
|
+
* key + title. */
|
|
146
|
+
function seedMission(envelope, now) {
|
|
147
|
+
return {
|
|
148
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
149
|
+
missionKey: envelope.subject.missionKey,
|
|
150
|
+
title: envelope.subject.title,
|
|
151
|
+
status: "active",
|
|
152
|
+
participants: [],
|
|
153
|
+
outcomes: [],
|
|
154
|
+
learnings: {},
|
|
155
|
+
importedLearnings: {},
|
|
156
|
+
createdAt: now,
|
|
157
|
+
updatedAt: now,
|
|
158
|
+
schemaVersion: 1,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Apply an envelope's payload to a mission record WITHOUT recomputing its
|
|
162
|
+
* `status` or `participants` (non-transitive) and WITHOUT touching first-party
|
|
163
|
+
* `learnings`. Only `importedLearnings`, `outcomes`, and `updatedAt` change. */
|
|
164
|
+
function applyEnvelopeToMission(record, envelope, fromAgentId, now) {
|
|
165
|
+
const importedLearnings = envelope.learnings && envelope.learnings.length > 0
|
|
166
|
+
? mergeImportedLearnings(record, envelope.learnings, fromAgentId, now)
|
|
167
|
+
: record.importedLearnings;
|
|
168
|
+
const outcomes = envelope.outcomes && envelope.outcomes.length > 0
|
|
169
|
+
? mergeImportedOutcomes(record.outcomes, envelope.outcomes, fromAgentId, now)
|
|
170
|
+
: record.outcomes;
|
|
171
|
+
return {
|
|
172
|
+
...record,
|
|
173
|
+
// status / participants are intentionally NOT recomputed — an import must
|
|
174
|
+
// never flip the mission's status (the non-transitive invariant).
|
|
175
|
+
...(importedLearnings ? { importedLearnings } : {}),
|
|
176
|
+
outcomes,
|
|
177
|
+
updatedAt: now,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Consumer half of the mission ledger — the non-clobbering merge. Resolves the
|
|
182
|
+
* mission by `findByMissionKey`; lands imported learnings in the
|
|
183
|
+
* `importedLearnings` namespace WITHOUT touching first-party `learnings`;
|
|
184
|
+
* append-merges + dedupes imported outcomes; NEVER recomputes status /
|
|
185
|
+
* participants; the source agent's trust caps acceptance; seeds an unknown
|
|
186
|
+
* mission only when a friend/family peer introduces it.
|
|
187
|
+
*/
|
|
188
|
+
async function importMissionShare(missions, input, options = {}) {
|
|
189
|
+
const verifier = options.verifier ?? verifier_1.DEFAULT_AGENT_VERIFIER;
|
|
190
|
+
const minTrust = options.minTrustToAccept ?? "acquaintance";
|
|
191
|
+
// Authentication (caller's seam) AND authorization (trust ladder) must BOTH
|
|
192
|
+
// pass. The verifier authenticates the wire; the trust cap is the package's own
|
|
193
|
+
// gate and applies regardless of what the verifier returns.
|
|
194
|
+
const authenticated = verifier.verify(input.fromAgentId, input.envelope.proof);
|
|
195
|
+
const trustedEnough = TRUST_RANK[input.trustOfSource] >= TRUST_RANK[minTrust];
|
|
196
|
+
if (!authenticated || !trustedEnough) {
|
|
197
|
+
(0, observability_1.emitNervesEvent)({
|
|
198
|
+
component: "friends",
|
|
199
|
+
event: "friends.mission_share_refused",
|
|
200
|
+
message: "refused mission share from untrusted source",
|
|
201
|
+
meta: { fromAgentId: input.fromAgentId, trustOfSource: input.trustOfSource, authenticated },
|
|
202
|
+
});
|
|
203
|
+
return { ok: false, status: "untrusted_source" };
|
|
204
|
+
}
|
|
205
|
+
const now = new Date().toISOString();
|
|
206
|
+
const existing = await missions.findByMissionKey(input.envelope.subject.missionKey);
|
|
207
|
+
if (!existing) {
|
|
208
|
+
// Unknown mission. Only a friend/family peer may seed a new one.
|
|
209
|
+
if (!SEEDING_TRUST.has(input.trustOfSource)) {
|
|
210
|
+
return { ok: false, status: "untrusted_introduction" };
|
|
211
|
+
}
|
|
212
|
+
const seeded = seedMission(input.envelope, now);
|
|
213
|
+
const withPayload = applyEnvelopeToMission(seeded, input.envelope, input.fromAgentId, now);
|
|
214
|
+
await missions.put(withPayload.id, withPayload);
|
|
215
|
+
(0, observability_1.emitNervesEvent)({
|
|
216
|
+
component: "friends",
|
|
217
|
+
event: "friends.mission_share_seeded",
|
|
218
|
+
message: "seeded new mission from mission share",
|
|
219
|
+
meta: { missionId: withPayload.id, fromAgentId: input.fromAgentId },
|
|
220
|
+
});
|
|
221
|
+
return { ok: true, status: "seeded", record: withPayload };
|
|
222
|
+
}
|
|
223
|
+
const updated = applyEnvelopeToMission(existing, input.envelope, input.fromAgentId, now);
|
|
224
|
+
await missions.put(updated.id, updated);
|
|
225
|
+
(0, observability_1.emitNervesEvent)({
|
|
226
|
+
component: "friends",
|
|
227
|
+
event: "friends.mission_share_imported",
|
|
228
|
+
message: "imported mission share into existing mission",
|
|
229
|
+
meta: { missionId: updated.id, fromAgentId: input.fromAgentId, scope: input.envelope.scope },
|
|
230
|
+
});
|
|
231
|
+
return { ok: true, status: "imported", record: updated };
|
|
232
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MissionStore } from "./mission-store";
|
|
2
|
+
import type { MissionRecord } from "./types";
|
|
3
|
+
/** The sibling missions directory for a given friends directory:
|
|
4
|
+
* `<friendsDir>/_missions`. The collection lives UNDER the friends dir (a
|
|
5
|
+
* reserved `_`-prefixed subdir) so a single `--dir` still points the whole
|
|
6
|
+
* substrate at one place. */
|
|
7
|
+
export declare function missionsDirFor(friendsDir: string): string;
|
|
8
|
+
export declare class FileMissionStore implements MissionStore {
|
|
9
|
+
private readonly missionsPath;
|
|
10
|
+
constructor(missionsPath: string);
|
|
11
|
+
get(id: string): Promise<MissionRecord | null>;
|
|
12
|
+
put(id: string, mission: MissionRecord): Promise<void>;
|
|
13
|
+
delete(id: string): Promise<void>;
|
|
14
|
+
findByMissionKey(missionKey: string): Promise<MissionRecord | null>;
|
|
15
|
+
listAll(): Promise<MissionRecord[]>;
|
|
16
|
+
private normalize;
|
|
17
|
+
private readJson;
|
|
18
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// FileMissionStore — filesystem adapter for MissionStore (brick 3).
|
|
3
|
+
// Stores each MissionRecord as one JSON file in a sibling `_missions/` collection
|
|
4
|
+
// next to the friends directory. Mirrors FileGrantStore's structure (mkdir on
|
|
5
|
+
// construct, one file per record, guarded reads, a `normalize` for round-trip
|
|
6
|
+
// discipline) so the three stores feel uniform.
|
|
7
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
+
if (k2 === undefined) k2 = k;
|
|
9
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
+
}
|
|
13
|
+
Object.defineProperty(o, k2, desc);
|
|
14
|
+
}) : (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
o[k2] = m[k];
|
|
17
|
+
}));
|
|
18
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
+
}) : function(o, v) {
|
|
21
|
+
o["default"] = v;
|
|
22
|
+
});
|
|
23
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
+
var ownKeys = function(o) {
|
|
25
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
+
var ar = [];
|
|
27
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
+
return ar;
|
|
29
|
+
};
|
|
30
|
+
return ownKeys(o);
|
|
31
|
+
};
|
|
32
|
+
return function (mod) {
|
|
33
|
+
if (mod && mod.__esModule) return mod;
|
|
34
|
+
var result = {};
|
|
35
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
+
__setModuleDefault(result, mod);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.FileMissionStore = void 0;
|
|
42
|
+
exports.missionsDirFor = missionsDirFor;
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const fsPromises = __importStar(require("fs/promises"));
|
|
45
|
+
const path = __importStar(require("path"));
|
|
46
|
+
const observability_1 = require("./observability");
|
|
47
|
+
const MISSION_STATUSES = new Set([
|
|
48
|
+
"active",
|
|
49
|
+
"succeeded",
|
|
50
|
+
"partial",
|
|
51
|
+
"failed",
|
|
52
|
+
"abandoned",
|
|
53
|
+
]);
|
|
54
|
+
/** The sibling missions directory for a given friends directory:
|
|
55
|
+
* `<friendsDir>/_missions`. The collection lives UNDER the friends dir (a
|
|
56
|
+
* reserved `_`-prefixed subdir) so a single `--dir` still points the whole
|
|
57
|
+
* substrate at one place. */
|
|
58
|
+
function missionsDirFor(friendsDir) {
|
|
59
|
+
return path.join(friendsDir, "_missions");
|
|
60
|
+
}
|
|
61
|
+
class FileMissionStore {
|
|
62
|
+
missionsPath;
|
|
63
|
+
constructor(missionsPath) {
|
|
64
|
+
this.missionsPath = missionsPath;
|
|
65
|
+
fs.mkdirSync(missionsPath, { recursive: true });
|
|
66
|
+
(0, observability_1.emitNervesEvent)({
|
|
67
|
+
component: "friends",
|
|
68
|
+
event: "friends.mission_store_init",
|
|
69
|
+
message: "file mission store initialized",
|
|
70
|
+
meta: {},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
async get(id) {
|
|
74
|
+
const mission = await this.readJson(path.join(this.missionsPath, `${id}.json`));
|
|
75
|
+
return mission ? this.normalize(mission) : null;
|
|
76
|
+
}
|
|
77
|
+
async put(id, mission) {
|
|
78
|
+
await fsPromises.writeFile(path.join(this.missionsPath, `${id}.json`), JSON.stringify(this.normalize(mission), null, 2), "utf-8");
|
|
79
|
+
}
|
|
80
|
+
async delete(id) {
|
|
81
|
+
try {
|
|
82
|
+
await fsPromises.unlink(path.join(this.missionsPath, `${id}.json`));
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (err?.code === "ENOENT")
|
|
86
|
+
return;
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async findByMissionKey(missionKey) {
|
|
91
|
+
const all = await this.listAll();
|
|
92
|
+
return all.find((m) => m.missionKey === missionKey) ?? null;
|
|
93
|
+
}
|
|
94
|
+
async listAll() {
|
|
95
|
+
let entries;
|
|
96
|
+
try {
|
|
97
|
+
entries = await fsPromises.readdir(this.missionsPath);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* v8 ignore next -- defensive: dir is mkdir'd in the constructor, so readdir
|
|
101
|
+
only throws if it's deleted mid-run; unreachable through the API @preserve */
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
const missions = [];
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (!entry.endsWith(".json"))
|
|
107
|
+
continue;
|
|
108
|
+
const raw = await this.readJson(path.join(this.missionsPath, entry));
|
|
109
|
+
if (!raw)
|
|
110
|
+
continue;
|
|
111
|
+
missions.push(this.normalize(raw));
|
|
112
|
+
}
|
|
113
|
+
return missions;
|
|
114
|
+
}
|
|
115
|
+
normalize(raw) {
|
|
116
|
+
return {
|
|
117
|
+
id: raw.id,
|
|
118
|
+
missionKey: raw.missionKey,
|
|
119
|
+
title: typeof raw.title === "string" ? raw.title : raw.missionKey,
|
|
120
|
+
status: typeof raw.status === "string" && MISSION_STATUSES.has(raw.status) ? raw.status : "active",
|
|
121
|
+
participants: Array.isArray(raw.participants) ? raw.participants : [],
|
|
122
|
+
outcomes: Array.isArray(raw.outcomes) ? raw.outcomes : [],
|
|
123
|
+
learnings: raw.learnings && typeof raw.learnings === "object" ? raw.learnings : {},
|
|
124
|
+
...(raw.importedLearnings && typeof raw.importedLearnings === "object" ? { importedLearnings: raw.importedLearnings } : {}),
|
|
125
|
+
// The coordination sub-object (brick 5) passes through like importedLearnings:
|
|
126
|
+
// present iff the record carries one, so a legacy mission with no coordination
|
|
127
|
+
// round-trips unchanged (absent ⇒ unclaimed).
|
|
128
|
+
...(raw.coordination && typeof raw.coordination === "object" ? { coordination: raw.coordination } : {}),
|
|
129
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : new Date().toISOString(),
|
|
130
|
+
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : new Date().toISOString(),
|
|
131
|
+
schemaVersion: typeof raw.schemaVersion === "number" ? raw.schemaVersion : 1,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async readJson(filePath) {
|
|
135
|
+
try {
|
|
136
|
+
const raw = await fsPromises.readFile(filePath, "utf-8");
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(raw);
|
|
139
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return parsed;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
exports.FileMissionStore = FileMissionStore;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MissionRecord } from "./types";
|
|
2
|
+
export interface MissionStore {
|
|
3
|
+
get(id: string): Promise<MissionRecord | null>;
|
|
4
|
+
put(id: string, mission: MissionRecord): Promise<void>;
|
|
5
|
+
delete(id: string): Promise<void>;
|
|
6
|
+
/** Resolve a mission by its cross-agent join key (the name on the wire), not
|
|
7
|
+
* the local UUID. Returns null when no mission carries that key. */
|
|
8
|
+
findByMissionKey(missionKey: string): Promise<MissionRecord | null>;
|
|
9
|
+
listAll(): Promise<MissionRecord[]>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Mission store abstraction (brick 3).
|
|
3
|
+
// MissionRecord state persists through MissionStore — a sibling to FriendStore /
|
|
4
|
+
// GrantStore, mirroring their shape. A mission is many-to-many with peers and has
|
|
5
|
+
// its own identity/lifecycle, so it lives in its own collection rather than on a
|
|
6
|
+
// friend record. Adds `findByMissionKey` (the cross-agent join-key lookup the
|
|
7
|
+
// consumer resolves on) over the get/put/delete/listAll quartet. No mission
|
|
8
|
+
// module imports `fs` directly except the FileMissionStore adapter.
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { MissionStore } from "./mission-store";
|
|
2
|
+
import type { AgentAttribution, MissionRecord, RelationshipOutcome } from "./types";
|
|
3
|
+
export interface RecordMissionInput {
|
|
4
|
+
missionKey: string;
|
|
5
|
+
/** Used only when CREATING; ignored on upsert of an existing mission. */
|
|
6
|
+
title?: string;
|
|
7
|
+
/** When provided, sets the mission's status (first-party). */
|
|
8
|
+
status?: MissionRecord["status"];
|
|
9
|
+
/** Merged into the mission's participants, deduped by agentId. */
|
|
10
|
+
participants?: AgentAttribution[];
|
|
11
|
+
/** Appended to the first-party `learnings` map (NEVER `importedLearnings`). */
|
|
12
|
+
learnings?: Array<{
|
|
13
|
+
key: string;
|
|
14
|
+
value: string;
|
|
15
|
+
shareable?: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
/** Appended to the mission's outcomes as first-party rows (no `origin:imported`). */
|
|
18
|
+
outcomes?: Array<{
|
|
19
|
+
missionId: string;
|
|
20
|
+
result: RelationshipOutcome["result"];
|
|
21
|
+
note?: string;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Upsert a mission by its `missionKey`. Creates a fresh record (new local UUID,
|
|
26
|
+
* `status:"active"`, empty namespaces) when the key is unknown; otherwise
|
|
27
|
+
* resolves the existing record and applies the input. First-party learnings are
|
|
28
|
+
* this agent's own — they NEVER touch `importedLearnings`. Returns the persisted
|
|
29
|
+
* record.
|
|
30
|
+
*/
|
|
31
|
+
export declare function recordMission(missions: MissionStore, input: RecordMissionInput): Promise<MissionRecord>;
|
package/dist/missions.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.recordMission = recordMission;
|
|
4
|
+
// recordMission — the first-party mission writer (brick 3).
|
|
5
|
+
//
|
|
6
|
+
// Upserts a MissionRecord by its cross-agent `missionKey`: creates one with a
|
|
7
|
+
// fresh local UUID when the key is unknown, else resolves the existing record via
|
|
8
|
+
// `findByMissionKey` and applies the input. First-party learnings land in
|
|
9
|
+
// `learnings` (this agent's own knowledge — NEVER in `importedLearnings`);
|
|
10
|
+
// participants merge deduped by agentId; outcomes append; status updates when
|
|
11
|
+
// provided. The companion to the UNTOUCHED `recordRelationshipOutcome` in
|
|
12
|
+
// `outcomes.ts` (that writer owns a friend's denormalized outcome index; this one
|
|
13
|
+
// owns the mission record). Pure store ops — no fs / net / env; the only node
|
|
14
|
+
// builtin is `node:crypto` (randomUUID), mirroring share.ts.
|
|
15
|
+
const node_crypto_1 = require("node:crypto");
|
|
16
|
+
const observability_1 = require("./observability");
|
|
17
|
+
/** Merge the input participants into the existing list, deduped by agentId. A
|
|
18
|
+
* participant whose agentId already appears is skipped (idempotent). */
|
|
19
|
+
function mergeParticipants(existing, incoming) {
|
|
20
|
+
const seen = new Set(existing.map((p) => p.agentId));
|
|
21
|
+
const merged = [...existing];
|
|
22
|
+
for (const p of incoming) {
|
|
23
|
+
if (!seen.has(p.agentId)) {
|
|
24
|
+
merged.push(p);
|
|
25
|
+
seen.add(p.agentId);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return merged;
|
|
29
|
+
}
|
|
30
|
+
/** Apply the input's first-party learnings onto the existing learnings map,
|
|
31
|
+
* stamping each with `savedAt` + first-party provenance. */
|
|
32
|
+
function applyLearnings(existing, incoming, now) {
|
|
33
|
+
const learnings = { ...existing };
|
|
34
|
+
for (const l of incoming) {
|
|
35
|
+
learnings[l.key] = {
|
|
36
|
+
value: l.value,
|
|
37
|
+
savedAt: now,
|
|
38
|
+
shareable: l.shareable ?? false,
|
|
39
|
+
provenance: { origin: "first_party" },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return learnings;
|
|
43
|
+
}
|
|
44
|
+
/** Append the input's first-party outcomes, stamping each with a timestamp. */
|
|
45
|
+
function applyOutcomes(existing, incoming, now) {
|
|
46
|
+
const outcomes = [...existing];
|
|
47
|
+
for (const o of incoming) {
|
|
48
|
+
outcomes.push({
|
|
49
|
+
missionId: o.missionId,
|
|
50
|
+
result: o.result,
|
|
51
|
+
timestamp: now,
|
|
52
|
+
...(o.note ? { note: o.note } : {}),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return outcomes;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Upsert a mission by its `missionKey`. Creates a fresh record (new local UUID,
|
|
59
|
+
* `status:"active"`, empty namespaces) when the key is unknown; otherwise
|
|
60
|
+
* resolves the existing record and applies the input. First-party learnings are
|
|
61
|
+
* this agent's own — they NEVER touch `importedLearnings`. Returns the persisted
|
|
62
|
+
* record.
|
|
63
|
+
*/
|
|
64
|
+
async function recordMission(missions, input) {
|
|
65
|
+
const now = new Date().toISOString();
|
|
66
|
+
const found = await missions.findByMissionKey(input.missionKey);
|
|
67
|
+
const base = found ?? {
|
|
68
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
69
|
+
missionKey: input.missionKey,
|
|
70
|
+
title: input.title ?? input.missionKey,
|
|
71
|
+
status: input.status ?? "active",
|
|
72
|
+
participants: [],
|
|
73
|
+
outcomes: [],
|
|
74
|
+
learnings: {},
|
|
75
|
+
importedLearnings: {},
|
|
76
|
+
createdAt: now,
|
|
77
|
+
updatedAt: now,
|
|
78
|
+
schemaVersion: 1,
|
|
79
|
+
};
|
|
80
|
+
const updated = {
|
|
81
|
+
...base,
|
|
82
|
+
// `status` is set from the input when provided (first-party). On create it is
|
|
83
|
+
// already baked into `base`; this re-applies it on an upsert.
|
|
84
|
+
status: input.status ?? base.status,
|
|
85
|
+
participants: input.participants ? mergeParticipants(base.participants, input.participants) : base.participants,
|
|
86
|
+
outcomes: input.outcomes ? applyOutcomes(base.outcomes, input.outcomes, now) : base.outcomes,
|
|
87
|
+
learnings: input.learnings ? applyLearnings(base.learnings, input.learnings, now) : base.learnings,
|
|
88
|
+
updatedAt: now,
|
|
89
|
+
};
|
|
90
|
+
await missions.put(updated.id, updated);
|
|
91
|
+
(0, observability_1.emitNervesEvent)({
|
|
92
|
+
component: "friends",
|
|
93
|
+
event: "friends.mission_recorded",
|
|
94
|
+
message: "recorded mission",
|
|
95
|
+
meta: { missionKey: input.missionKey, created: found === null, status: updated.status },
|
|
96
|
+
});
|
|
97
|
+
return updated;
|
|
98
|
+
}
|
package/dist/notes.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FriendStore } from "./store";
|
|
2
|
+
import type { NoteProvenance } from "./types";
|
|
3
|
+
import type { FriendOpResult } from "./results";
|
|
4
|
+
export interface ApplyFriendNoteInput {
|
|
5
|
+
type: "name" | "tool_preference" | "note";
|
|
6
|
+
key?: string;
|
|
7
|
+
content: string;
|
|
8
|
+
override?: boolean;
|
|
9
|
+
provenance?: NoteProvenance;
|
|
10
|
+
}
|
|
11
|
+
export declare function applyFriendNote(store: FriendStore, friendId: string, input: ApplyFriendNoteInput): Promise<FriendOpResult>;
|