@xpufx/paseo-x-comms 0.3.0
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 +21 -0
- package/README.md +149 -0
- package/client/attribution.ts +7 -0
- package/client/conversations.ts +229 -0
- package/client/main.tsx +885 -0
- package/client/peer-label.ts +98 -0
- package/client/peer-status.tsx +201 -0
- package/client/settings-prototype.tsx +395 -0
- package/client/tool-call.ts +115 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/client/via-x-comms.tsx +15 -0
- package/client/x-comms-conversation.tsx +449 -0
- package/client/x-comms-panel.tsx +6 -0
- package/client/x-comms-pill.tsx +193 -0
- package/client/x-comms-timeline.tsx +176 -0
- package/client/x-comms-tool-call.tsx +85 -0
- package/docs/mesh.md +115 -0
- package/mcp/LICENSE +202 -0
- package/mcp/README.md +287 -0
- package/mcp/mcp-config.example.json +8 -0
- package/mcp/package-lock.json +1186 -0
- package/mcp/package.json +43 -0
- package/mcp/paseo-cross-daemon-comms.example.json +5 -0
- package/mcp/paseo-x-comms.bundled.mjs +36964 -0
- package/mcp/paseo-x-comms.mjs +630 -0
- package/mcp/test/fixtures/extensions/block/10-block.mjs +4 -0
- package/mcp/test/fixtures/extensions/broken-hook/10-thrower.mjs +6 -0
- package/mcp/test/fixtures/extensions/broken-hook/20-good.mjs +4 -0
- package/mcp/test/fixtures/extensions/broken-load/10-thrower.mjs +4 -0
- package/mcp/test/fixtures/extensions/broken-load/20-good.mjs +4 -0
- package/mcp/test/fixtures/extensions/custom-tool/10-tool.mjs +12 -0
- package/mcp/test/fixtures/extensions/tool-block/10-block.mjs +8 -0
- package/mcp/test/fixtures/extensions/transform/10-transform.mjs +15 -0
- package/mcp/test/fixtures/fake-paseo.mjs +92 -0
- package/mcp/test/register-ts-hooks.mjs +6 -0
- package/mcp/test/resolve-ts-hooks.mjs +46 -0
- package/package.json +58 -0
- package/paseo-plugin.json +7 -0
- package/server/conversations-snapshot.ts +249 -0
- package/server/handlers.ts +1081 -0
- package/server/injection.ts +152 -0
- package/server/local-send.ts +153 -0
- package/server/mcp-client.ts +47 -0
- package/server/outbox.ts +233 -0
- package/server/peer-channel.ts +151 -0
- package/server/peer-status.ts +233 -0
- package/server/presence.ts +204 -0
- package/server/registry.ts +215 -0
- package/server/relay-status.ts +9 -0
- package/server/server-status.ts +138 -0
- package/server/settings.ts +81 -0
- package/server/snapshot.ts +176 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp/client.ts +349 -0
- package/server/vendor/paseo-plugin-helper/mcp/http-client.ts +399 -0
- package/server/vendor/paseo-plugin-helper/mcp/index.ts +5 -0
- package/server/vendor/paseo-plugin-helper/mcp/process-killer.ts +65 -0
- package/server/vendor/paseo-plugin-helper/mcp/ring-buffer.ts +29 -0
- package/server/vendor/paseo-plugin-helper/mcp/types.ts +41 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/conversations-snapshot.ts +39 -0
- package/shared/envelope.ts +92 -0
- package/shared/outbox.ts +21 -0
- package/shared/registry.ts +366 -0
- package/shared/vendor/paseo-plugin-helper/README.md +11 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
- package/shared/version.ts +2 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Test fixture: exercises all three filters, using both the bare-return and the
|
|
2
|
+
// explicit { action: "transform" } result forms.
|
|
3
|
+
export default function register(api) {
|
|
4
|
+
api.onToolCall((args, context) => {
|
|
5
|
+
if (context.tool !== "x_comms_inspect") return undefined;
|
|
6
|
+
return { ...args, agentId: `${args.agentId}-rewritten` };
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
api.onSend((message) => ({ ...message, prompt: `[EXT] ${message.prompt}` }));
|
|
10
|
+
|
|
11
|
+
api.onReceive((data, context) => {
|
|
12
|
+
if (context.tool !== "x_comms_inspect") return undefined;
|
|
13
|
+
return { action: "transform", value: { ...data, extTag: "seen" } };
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hermetic stand-in for the paseo CLI. Only used by tests via PASEO_X_COMMS_PASEO.
|
|
3
|
+
// Never called by the real tool outside of tests.
|
|
4
|
+
//
|
|
5
|
+
// Test controls via env:
|
|
6
|
+
// FAKE_PASEO_DELAY_MS respond after a delay (tests cancellation / exit discipline)
|
|
7
|
+
// FAKE_PASEO_FAIL exit 1 with this message on stderr (tests error paths)
|
|
8
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const delay = Number(process.env.FAKE_PASEO_DELAY_MS || 0);
|
|
12
|
+
const fail = process.env.FAKE_PASEO_FAIL;
|
|
13
|
+
|
|
14
|
+
function argAfter(name) {
|
|
15
|
+
const i = args.indexOf(name);
|
|
16
|
+
return i >= 0 ? args[i + 1] ?? null : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sub = args[0];
|
|
20
|
+
const host = argAfter("--host");
|
|
21
|
+
const prompt = args[args.length - 1];
|
|
22
|
+
|
|
23
|
+
function respond(data) {
|
|
24
|
+
if (delay > 0) {
|
|
25
|
+
// Keep the event loop alive across the delay; SIGTERM still kills us (no
|
|
26
|
+
// signal listener installed), which is what the cancellation test relies on.
|
|
27
|
+
sleep(delay).then(() => write(data));
|
|
28
|
+
} else {
|
|
29
|
+
write(data);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function write(data) {
|
|
33
|
+
process.stdout.write(JSON.stringify(data) + "\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (fail) {
|
|
37
|
+
console.error(fail);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
switch (sub) {
|
|
42
|
+
case "daemon":
|
|
43
|
+
respond({ serverId: "srv_fake", hostname: "fakehost" });
|
|
44
|
+
break;
|
|
45
|
+
case "inspect":
|
|
46
|
+
respond({ Id: args[1], Name: "fake-agent", sawHost: host });
|
|
47
|
+
break;
|
|
48
|
+
case "ls":
|
|
49
|
+
respond([{ id: "agent-1", shortId: "agent-1", name: "fake-agent", status: "idle", sawHost: host }]);
|
|
50
|
+
break;
|
|
51
|
+
case "send":
|
|
52
|
+
respond({ ok: true, to: args[1], sawHost: host, sawNoWait: args.includes("--no-wait"), promptHead: prompt });
|
|
53
|
+
break;
|
|
54
|
+
case "logs":
|
|
55
|
+
respond({ events: [], sawHost: host });
|
|
56
|
+
break;
|
|
57
|
+
case "wait":
|
|
58
|
+
respond({
|
|
59
|
+
agentId: args[1],
|
|
60
|
+
status: process.env.FAKE_PASEO_WAIT_STATUS || "idle",
|
|
61
|
+
message: process.env.FAKE_PASEO_WAIT_STATUS === "permission"
|
|
62
|
+
? "Agent is waiting for permission: external_directory"
|
|
63
|
+
: "Agent is idle.",
|
|
64
|
+
sawHost: host,
|
|
65
|
+
sawTimeout: argAfter("--timeout"),
|
|
66
|
+
});
|
|
67
|
+
break;
|
|
68
|
+
case "permit": {
|
|
69
|
+
const sub = args[1];
|
|
70
|
+
if (sub === "ls") {
|
|
71
|
+
respond(process.env.FAKE_PASEO_PERMISSIONS ? JSON.parse(process.env.FAKE_PASEO_PERMISSIONS) : []);
|
|
72
|
+
} else if (sub === "allow" || sub === "deny") {
|
|
73
|
+
// positional agent is args[2]; positional reqId is args[3] (if no options before it)
|
|
74
|
+
const positionals = args.slice(2).filter((a) => !a.startsWith("-"));
|
|
75
|
+
const item = {
|
|
76
|
+
requestId: positionals[1] ?? "all",
|
|
77
|
+
agentId: positionals[0],
|
|
78
|
+
result: sub === "allow" ? "allowed" : "denied",
|
|
79
|
+
sawHost: host,
|
|
80
|
+
sawAll: args.includes("--all"),
|
|
81
|
+
};
|
|
82
|
+
respond(sub === "allow" ? [item] : { data: [item] });
|
|
83
|
+
} else {
|
|
84
|
+
console.error(`fake-paseo: unhandled permit subcommand: ${sub}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
default:
|
|
90
|
+
console.error(`fake-paseo: unhandled args: ${args.join(" ")}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Registered via `node --import` (see package.json test script): installs the
|
|
2
|
+
// TS-extension resolve hooks for the test process. Separate file because a
|
|
3
|
+
// hooks module must be registered, not merely imported.
|
|
4
|
+
import { register } from "node:module";
|
|
5
|
+
|
|
6
|
+
register("./resolve-ts-hooks.mjs", import.meta.url);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Dev-only ESM resolution hook for `node --test` (see package.json test script).
|
|
2
|
+
// The vendored helper (client/server/shared/vendor/paseo-plugin-helper/) uses
|
|
3
|
+
// extensionless relative imports, which the daemon's esbuild bundler and tsc
|
|
4
|
+
// resolve fine but plain node (type-stripping, no loader) does not. This hook
|
|
5
|
+
// maps extensionless relative specifiers to their .ts/.tsx/index files.
|
|
6
|
+
// Track B of issue #71 (plugin #101).
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
|
|
10
|
+
const EXTENSIONS = [".ts", ".tsx"];
|
|
11
|
+
const INDEXES = ["index.ts", "index.tsx"];
|
|
12
|
+
|
|
13
|
+
function tryFile(url) {
|
|
14
|
+
try {
|
|
15
|
+
if (fs.statSync(url, { throwIfNoEntry: false })?.isFile()) return url.href;
|
|
16
|
+
} catch {
|
|
17
|
+
// fall through
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function resolve(specifier, context, next) {
|
|
23
|
+
try {
|
|
24
|
+
return await next(specifier, context);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (
|
|
27
|
+
err?.code !== "ERR_MODULE_NOT_FOUND" ||
|
|
28
|
+
!specifier.startsWith(".") ||
|
|
29
|
+
/\.[a-z]+$/i.test(specifier)
|
|
30
|
+
) {
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
const parentPath = fileURLToPath(context.parentURL);
|
|
34
|
+
const base = new URL(specifier, pathToFileURL(parentPath));
|
|
35
|
+
const basePath = base.pathname;
|
|
36
|
+
for (const ext of EXTENSIONS) {
|
|
37
|
+
const hit = tryFile(new URL(`file://${basePath}${ext}`));
|
|
38
|
+
if (hit) return { url: hit, shortCircuit: true };
|
|
39
|
+
}
|
|
40
|
+
for (const index of INDEXES) {
|
|
41
|
+
const hit = tryFile(new URL(`file://${basePath}/${index}`));
|
|
42
|
+
if (hit) return { url: hit, shortCircuit: true };
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xpufx/paseo-x-comms",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Cross-daemon agent conversation over Paseo Relay: a Paseo plugin that bundles the MCP server so agents on one daemon can talk to agents on another (relay offer or direct host).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"bin": {
|
|
8
|
+
"paseo-x-comms": "./mcp/paseo-x-comms.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"stamp": "node -e \"import('./server/vendor/paseo-plugin-helper/version.ts').then(m => m.stampVersion({ targetFile: 'shared/version.ts' }))\"",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"test": "node --import ./mcp/test/register-ts-hooks.mjs --test mcp/test/protocol.test.mjs server/presence.test.ts server/outbox.test.ts server/injection.test.ts server/settings.test.ts server/server-status.test.ts server/relay-status.test.ts server/conversations-snapshot.test.ts server/local-send.test.ts server/peer-status.test.ts client/tool-call.test.ts client/conversation-threads.test.ts client/attribution.test.ts client/message-direction.test.ts"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"paseo",
|
|
17
|
+
"mcp",
|
|
18
|
+
"agent",
|
|
19
|
+
"relay",
|
|
20
|
+
"e2ee",
|
|
21
|
+
"daemon",
|
|
22
|
+
"cross-daemon",
|
|
23
|
+
"plugin"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@getpaseo/client": "0.9.0-beta.2",
|
|
28
|
+
"@getpaseo/protocol": "0.9.0-beta.2",
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.30.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@getpaseo/plugin": "0.9.0-beta.2",
|
|
33
|
+
"@tanstack/react-query": "^5.90.11",
|
|
34
|
+
"@types/react": "^19.0.10",
|
|
35
|
+
"react": "19.1.0",
|
|
36
|
+
"react-native": "0.81.5",
|
|
37
|
+
"typescript": "^5.9.3",
|
|
38
|
+
"zod": "^4.4.3"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/xpufx/paseo.git"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"paseo-plugin.json",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE",
|
|
48
|
+
"client",
|
|
49
|
+
"server",
|
|
50
|
+
"shared",
|
|
51
|
+
"mcp",
|
|
52
|
+
"docs",
|
|
53
|
+
"!**/*.test.ts",
|
|
54
|
+
"!**/*.test.tsx",
|
|
55
|
+
"!**/*.test.mjs",
|
|
56
|
+
"!**/.DS_Store"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { PluginStorage } from "paseo-plugin-helper/server";
|
|
2
|
+
import {
|
|
3
|
+
emptyConversationsSnapshot,
|
|
4
|
+
type XCommsConversationsSnapshot,
|
|
5
|
+
type XCommsThreadSnapshot,
|
|
6
|
+
} from "../shared/conversations-snapshot.ts";
|
|
7
|
+
import { parseEnvelope } from "../shared/envelope.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Conversations snapshot writer (mesh visibility input). Daemon-local view
|
|
11
|
+
* of active x-comms threads, maintained without timeline polling by
|
|
12
|
+
* readers: writers update on send and on timeline reconcile (receive),
|
|
13
|
+
* readers consume the file. Plain data in, plain data out.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const CONVERSATIONS_SNAPSHOT_FILE = "conversations.json";
|
|
17
|
+
export const CONVERSATIONS_THREAD_CAP = 200;
|
|
18
|
+
|
|
19
|
+
export interface SendRecord {
|
|
20
|
+
peerAlias: string;
|
|
21
|
+
peerServerId: string;
|
|
22
|
+
peerAgentId: string;
|
|
23
|
+
peerAgentName: string | null;
|
|
24
|
+
localAgentId: string | null;
|
|
25
|
+
at: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TimelineEntryLike {
|
|
29
|
+
item?: { type?: string; text?: string } | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TimelineOwnerLike {
|
|
33
|
+
ownerAgentId: string;
|
|
34
|
+
entries: TimelineEntryLike[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Minimal structural surface needed to scan local timelines. The real
|
|
39
|
+
* PaseoApi satisfies this; mocks in tests do too.
|
|
40
|
+
*/
|
|
41
|
+
export interface TimelineScanner {
|
|
42
|
+
agents: {
|
|
43
|
+
list(): Promise<{ entries: Array<{ agent: { id: string } }> }>;
|
|
44
|
+
ref(id: string): {
|
|
45
|
+
timeline: { refetch(): Promise<{ entries: Array<unknown> }> };
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function asTimelineEntry(raw: unknown): TimelineEntryLike | null {
|
|
51
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
52
|
+
const item = (raw as { item?: unknown }).item;
|
|
53
|
+
if (typeof item !== "object" || item === null) return null;
|
|
54
|
+
const { type, text } = item as { type?: unknown; text?: unknown };
|
|
55
|
+
return {
|
|
56
|
+
item: {
|
|
57
|
+
type: typeof type === "string" ? type : undefined,
|
|
58
|
+
text: typeof text === "string" ? text : undefined,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pull every local agent's timeline and keep entries that carry the
|
|
65
|
+
* x-comms envelope. Per-agent failures are skipped, never fatal.
|
|
66
|
+
*/
|
|
67
|
+
export async function scanLocalTimelines(scanner: TimelineScanner): Promise<TimelineOwnerLike[]> {
|
|
68
|
+
const listed = await scanner.agents.list();
|
|
69
|
+
const out: TimelineOwnerLike[] = [];
|
|
70
|
+
for (const { agent } of listed.entries) {
|
|
71
|
+
try {
|
|
72
|
+
const page = await scanner.agents.ref(agent.id).timeline.refetch();
|
|
73
|
+
const entries: TimelineEntryLike[] = [];
|
|
74
|
+
for (const raw of page.entries) {
|
|
75
|
+
const entry = asTimelineEntry(raw);
|
|
76
|
+
if (entry) entries.push(entry);
|
|
77
|
+
}
|
|
78
|
+
out.push({ ownerAgentId: agent.id, entries });
|
|
79
|
+
} catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function threadKey(peerServerId: string, peerAgentId: string): string {
|
|
87
|
+
return `${peerServerId}/${peerAgentId}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function withLocalAgentIds(existing: string[], agentId: string | null): string[] {
|
|
91
|
+
if (!agentId || existing.includes(agentId)) return existing;
|
|
92
|
+
return [...existing, agentId];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Record an outbound send. Sending implies caught-up: unread resets and the
|
|
97
|
+
* read watermark advances to now.
|
|
98
|
+
*/
|
|
99
|
+
export function recordSend(
|
|
100
|
+
snapshot: XCommsConversationsSnapshot,
|
|
101
|
+
send: SendRecord,
|
|
102
|
+
): XCommsConversationsSnapshot {
|
|
103
|
+
const key = threadKey(send.peerServerId, send.peerAgentId);
|
|
104
|
+
const threads = snapshot.threads.filter((t) => threadKey(t.peerServerId, t.peerAgentId) !== key);
|
|
105
|
+
threads.push({
|
|
106
|
+
peerAlias: send.peerAlias,
|
|
107
|
+
peerServerId: send.peerServerId,
|
|
108
|
+
peerAgentId: send.peerAgentId,
|
|
109
|
+
peerAgentName: send.peerAgentName,
|
|
110
|
+
localAgentIds: withLocalAgentIds(
|
|
111
|
+
snapshot.threads.find((t) => threadKey(t.peerServerId, t.peerAgentId) === key)?.localAgentIds ?? [],
|
|
112
|
+
send.localAgentId,
|
|
113
|
+
),
|
|
114
|
+
lastDirection: "outgoing",
|
|
115
|
+
lastTimestamp: send.at,
|
|
116
|
+
lastReadAt: send.at,
|
|
117
|
+
lastSeenAt: send.at,
|
|
118
|
+
unreadCount: 0,
|
|
119
|
+
});
|
|
120
|
+
return capThreads({ ...snapshot, threads, updatedAt: send.at });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Reconcile inbound envelopes found in local agents' timelines. Only
|
|
125
|
+
* messages newer than the thread's read watermark increment unread, so
|
|
126
|
+
* rescans never double-count.
|
|
127
|
+
*/
|
|
128
|
+
export function reconcileTimelines(
|
|
129
|
+
snapshot: XCommsConversationsSnapshot,
|
|
130
|
+
timelines: TimelineOwnerLike[],
|
|
131
|
+
peerAliasFor: (serverId: string) => string | null,
|
|
132
|
+
nowIso: string = new Date().toISOString(),
|
|
133
|
+
): XCommsConversationsSnapshot {
|
|
134
|
+
let threads = [...snapshot.threads];
|
|
135
|
+
const upsert = (entry: Omit<XCommsThreadSnapshot, "localAgentIds" | "lastReadAt" | "lastSeenAt" | "unreadCount"> & {
|
|
136
|
+
localAgentId: string;
|
|
137
|
+
}) => {
|
|
138
|
+
const key = threadKey(entry.peerServerId, entry.peerAgentId);
|
|
139
|
+
const existing = threads.find((t) => threadKey(t.peerServerId, t.peerAgentId) === key);
|
|
140
|
+
if (!existing) {
|
|
141
|
+
threads.push({
|
|
142
|
+
...entry,
|
|
143
|
+
localAgentIds: [entry.localAgentId],
|
|
144
|
+
lastReadAt: new Date(0).toISOString(),
|
|
145
|
+
lastSeenAt: entry.lastTimestamp,
|
|
146
|
+
unreadCount: 1,
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const fresh = entry.lastTimestamp > existing.lastReadAt && entry.lastTimestamp > existing.lastSeenAt;
|
|
151
|
+
const seenAt = entry.lastTimestamp > existing.lastSeenAt ? entry.lastTimestamp : existing.lastSeenAt;
|
|
152
|
+
threads = threads.map((t) =>
|
|
153
|
+
threadKey(t.peerServerId, t.peerAgentId) === key
|
|
154
|
+
? {
|
|
155
|
+
...t,
|
|
156
|
+
peerAlias: entry.peerAlias,
|
|
157
|
+
peerAgentName: entry.peerAgentName ?? t.peerAgentName,
|
|
158
|
+
localAgentIds: withLocalAgentIds(t.localAgentIds, entry.localAgentId),
|
|
159
|
+
lastDirection: "incoming",
|
|
160
|
+
lastTimestamp: entry.lastTimestamp > t.lastTimestamp ? entry.lastTimestamp : t.lastTimestamp,
|
|
161
|
+
lastSeenAt: seenAt,
|
|
162
|
+
unreadCount: fresh ? t.unreadCount + 1 : t.unreadCount,
|
|
163
|
+
}
|
|
164
|
+
: t,
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
for (const timeline of timelines) {
|
|
168
|
+
for (const entry of timeline.entries) {
|
|
169
|
+
const text = entry.item?.text;
|
|
170
|
+
if (!text) continue;
|
|
171
|
+
const parsed = parseEnvelope(text);
|
|
172
|
+
if (!parsed) continue;
|
|
173
|
+
const sender = parsed.envelope.xComms.sender;
|
|
174
|
+
if (!sender.agentId || !sender.daemonServerId) continue;
|
|
175
|
+
upsert({
|
|
176
|
+
peerAlias: peerAliasFor(sender.daemonServerId) ?? sender.host ?? "unknown",
|
|
177
|
+
peerServerId: sender.daemonServerId,
|
|
178
|
+
peerAgentId: sender.agentId,
|
|
179
|
+
peerAgentName: sender.agentName,
|
|
180
|
+
localAgentId: timeline.ownerAgentId,
|
|
181
|
+
lastDirection: "incoming",
|
|
182
|
+
lastTimestamp: parsed.envelope.xComms.sentAt,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return capThreads({ ...snapshot, threads, updatedAt: nowIso });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Drop threads for a peer agent that retracted (or whose link died).
|
|
191
|
+
* Returns true when anything was removed.
|
|
192
|
+
*/
|
|
193
|
+
export function prunePeer(
|
|
194
|
+
snapshot: XCommsConversationsSnapshot,
|
|
195
|
+
peerServerId: string,
|
|
196
|
+
peerAgentId: string,
|
|
197
|
+
): { snapshot: XCommsConversationsSnapshot; removed: boolean } {
|
|
198
|
+
const key = threadKey(peerServerId, peerAgentId);
|
|
199
|
+
const threads = snapshot.threads.filter((t) => threadKey(t.peerServerId, t.peerAgentId) !== key);
|
|
200
|
+
return { snapshot: { ...snapshot, threads }, removed: threads.length !== snapshot.threads.length };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Detach a local agent (archive): remove it from every thread's local
|
|
205
|
+
* participants, dropping threads with nobody left.
|
|
206
|
+
*/
|
|
207
|
+
export function detachLocalAgent(
|
|
208
|
+
snapshot: XCommsConversationsSnapshot,
|
|
209
|
+
localAgentId: string,
|
|
210
|
+
): { snapshot: XCommsConversationsSnapshot; removed: boolean } {
|
|
211
|
+
const threads = snapshot.threads
|
|
212
|
+
.map((t) => ({ ...t, localAgentIds: t.localAgentIds.filter((id) => id !== localAgentId) }))
|
|
213
|
+
.filter((t) => t.localAgentIds.length > 0);
|
|
214
|
+
return { snapshot: { ...snapshot, threads }, removed: threads.length !== snapshot.threads.length };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function capThreads(snapshot: XCommsConversationsSnapshot): XCommsConversationsSnapshot {
|
|
218
|
+
if (snapshot.threads.length <= CONVERSATIONS_THREAD_CAP) return snapshot;
|
|
219
|
+
const threads = [...snapshot.threads]
|
|
220
|
+
.sort((a, b) => (a.lastTimestamp < b.lastTimestamp ? 1 : -1))
|
|
221
|
+
.slice(0, CONVERSATIONS_THREAD_CAP);
|
|
222
|
+
return { ...snapshot, threads };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const conversationsStore = new PluginStorage<XCommsConversationsSnapshot>(
|
|
226
|
+
"paseo-x-comms",
|
|
227
|
+
CONVERSATIONS_SNAPSHOT_FILE,
|
|
228
|
+
{ defaultData: emptyConversationsSnapshot() },
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
export function conversationsSnapshotPath(): string {
|
|
232
|
+
return conversationsStore.filePath;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function readConversationsSnapshot(): XCommsConversationsSnapshot {
|
|
236
|
+
try {
|
|
237
|
+
const data = conversationsStore.read();
|
|
238
|
+
if (data && typeof data === "object" && Array.isArray((data as { threads?: unknown }).threads)) {
|
|
239
|
+
return data as XCommsConversationsSnapshot;
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// Corrupt state falls back to empty; writers rebuild it.
|
|
243
|
+
}
|
|
244
|
+
return emptyConversationsSnapshot();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function writeConversationsSnapshot(snapshot: XCommsConversationsSnapshot): void {
|
|
248
|
+
conversationsStore.write(snapshot);
|
|
249
|
+
}
|