agenticros 0.1.17 → 0.1.19

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.
Files changed (100) hide show
  1. package/dist/__tests__/robot-config.test.d.ts +2 -0
  2. package/dist/__tests__/robot-config.test.d.ts.map +1 -0
  3. package/dist/__tests__/robot-config.test.js +374 -0
  4. package/dist/__tests__/robot-config.test.js.map +1 -0
  5. package/dist/__tests__/transport-shorthand.test.d.ts +2 -0
  6. package/dist/__tests__/transport-shorthand.test.d.ts.map +1 -0
  7. package/dist/__tests__/transport-shorthand.test.js +88 -0
  8. package/dist/__tests__/transport-shorthand.test.js.map +1 -0
  9. package/dist/commands/down.d.ts.map +1 -1
  10. package/dist/commands/down.js +55 -3
  11. package/dist/commands/down.js.map +1 -1
  12. package/dist/commands/robots.d.ts +57 -0
  13. package/dist/commands/robots.d.ts.map +1 -0
  14. package/dist/commands/robots.js +678 -0
  15. package/dist/commands/robots.js.map +1 -0
  16. package/dist/index.js +28 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/util/mcp-discovery.d.ts +57 -0
  19. package/dist/util/mcp-discovery.d.ts.map +1 -0
  20. package/dist/util/mcp-discovery.js +153 -0
  21. package/dist/util/mcp-discovery.js.map +1 -0
  22. package/dist/util/robot-config.d.ts +167 -0
  23. package/dist/util/robot-config.d.ts.map +1 -0
  24. package/dist/util/robot-config.js +375 -0
  25. package/dist/util/robot-config.js.map +1 -0
  26. package/dist/util/transport-shorthand.d.ts +66 -0
  27. package/dist/util/transport-shorthand.d.ts.map +1 -0
  28. package/dist/util/transport-shorthand.js +102 -0
  29. package/dist/util/transport-shorthand.js.map +1 -0
  30. package/package.json +2 -1
  31. package/runtime/BUNDLE.json +1 -1
  32. package/runtime/README.md +4 -0
  33. package/runtime/package.json +6 -1
  34. package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
  35. package/runtime/packages/agenticros/package.json +2 -1
  36. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
  37. package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
  38. package/runtime/packages/agenticros/src/service.ts +120 -2
  39. package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
  40. package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
  41. package/runtime/packages/agenticros/src/tools/index.ts +49 -9
  42. package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
  43. package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
  44. package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
  45. package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
  46. package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
  47. package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
  48. package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
  49. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
  50. package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
  51. package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
  52. package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
  53. package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
  54. package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
  55. package/runtime/packages/agenticros-claude-code/package.json +2 -1
  56. package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
  57. package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
  58. package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
  59. package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
  60. package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
  61. package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
  62. package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
  63. package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
  64. package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
  65. package/runtime/packages/agenticros-gemini/package.json +1 -0
  66. package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
  67. package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
  68. package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
  69. package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
  70. package/runtime/packages/core/package.json +1 -1
  71. package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
  72. package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
  73. package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
  74. package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
  75. package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
  76. package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
  77. package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
  78. package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
  79. package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
  80. package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
  81. package/runtime/packages/core/src/capabilities.ts +340 -0
  82. package/runtime/packages/core/src/config.ts +220 -38
  83. package/runtime/packages/core/src/discovery.ts +193 -0
  84. package/runtime/packages/core/src/find-robots-for.ts +164 -0
  85. package/runtime/packages/core/src/index.ts +67 -1
  86. package/runtime/packages/core/src/mission-registry.ts +142 -0
  87. package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
  88. package/runtime/packages/core/src/mission.ts +574 -0
  89. package/runtime/packages/core/src/planner/index.ts +564 -0
  90. package/runtime/packages/core/src/robots.ts +282 -0
  91. package/runtime/packages/core/src/topic-utils.ts +47 -9
  92. package/runtime/packages/core/src/transport-pool.ts +171 -0
  93. package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
  94. package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
  95. package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
  96. package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
  97. package/runtime/scripts/refresh-skill-deps.mjs +381 -0
  98. package/runtime/scripts/setup_gateway_plugin.sh +32 -5
  99. package/runtime/scripts/start_demo.sh +26 -3
  100. package/runtime/scripts/sync-skill-tools.mjs +67 -10
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Multi-robot discovery — Phase 1.d "fleet awareness on the wire".
3
+ *
4
+ * Configuration tells the gateway what robots the user wants to talk to.
5
+ * The transport tells the gateway what's actually on the wire right now.
6
+ * Discovery is the bridge: scan published ROS2 topics, infer robot
7
+ * namespaces from `<ns>/cmd_vel` patterns, cross-reference against the
8
+ * configured robot list, and surface three categories:
9
+ *
10
+ * 1. configured_online — in config AND seen on the wire
11
+ * 2. configured_offline — in config but NOT seen on the wire
12
+ * 3. unknown_detected — on the wire but NOT in config
13
+ *
14
+ * The agent uses this to answer questions like "which robots can I talk
15
+ * to right now?" without forcing the user to hand-edit
16
+ * `~/.agenticros/config.json` every time a robot joins the network.
17
+ *
18
+ * Persistence is intentionally OUT of scope here: discovery is read-only.
19
+ * Writing newly-detected robots into the user's config is a separate
20
+ * UX decision handled by the CLI / OpenClaw config UI on top of this
21
+ * function.
22
+ *
23
+ * ## Detection signal
24
+ *
25
+ * `<ns>/cmd_vel` (the velocity command topic) is the canonical signal —
26
+ * a robot that can be driven publishes/subscribes there. Other topics
27
+ * like `<ns>/odom`, `<ns>/joint_states`, and `<ns>/camera/...` corroborate
28
+ * the same `<ns>`; we surface them as `topicCount` so consumers can
29
+ * gauge how "alive" the namespace looks.
30
+ *
31
+ * The unnamespaced `/cmd_vel` topic (default namespace, common in
32
+ * simulation) is detected as id="" — consumers can render it as
33
+ * "default robot" or similar.
34
+ *
35
+ * ## Namespace rewrite
36
+ *
37
+ * `ros2_publish` rewrites a UUID-shaped namespace like
38
+ * `3946b404-c33e-...` into `robot3946b404c33e...` (strip dashes,
39
+ * prefix with "robot") because most robot firmware expects the
40
+ * dashless form. Discovery applies the same rewrite when matching
41
+ * configured robots against detected namespaces, so a UUID-style
42
+ * config entry still matches the `/robot<no-dashes>/cmd_vel` topic
43
+ * the robot is actually publishing on.
44
+ */
45
+
46
+ import type { AgenticROSConfig } from "./config.js";
47
+ import type { ResolvedRobot } from "./robots.js";
48
+ import { listRobots } from "./robots.js";
49
+ import type { TopicInfo } from "./transport/types.js";
50
+
51
+ /** One robot inferred from the topic graph. */
52
+ export interface DetectedRobot {
53
+ /** The namespace segment, e.g. "robot3946b...". Empty string = default namespace. */
54
+ id: string;
55
+ /** The full cmd_vel topic name we saw — e.g. "/robot3946b.../cmd_vel". */
56
+ cmdVelTopic: string;
57
+ /**
58
+ * Total topics under `/<id>/`, INCLUDING the cmd_vel topic itself.
59
+ * A topicCount of 1 means the robot is "barely there" (just cmd_vel,
60
+ * no odom / joint_states / camera etc.); higher counts corroborate
61
+ * that the namespace is actually a live robot, not a stale advert.
62
+ * Always 0 for the unnamespaced default robot (id="").
63
+ */
64
+ topicCount: number;
65
+ /**
66
+ * The matching `robot.id` from `config.robots` (or the legacy fallback)
67
+ * when this detected namespace maps to a configured robot, otherwise
68
+ * `null`. Matching uses the same UUID→robot-no-dashes rewrite as the
69
+ * publish path.
70
+ */
71
+ configuredRobotId: string | null;
72
+ }
73
+
74
+ /** Result of a discovery pass. */
75
+ export interface RobotDiscoveryResult {
76
+ /** Every robot inferred from the topic graph, with config match annotations. */
77
+ detected: DetectedRobot[];
78
+ /** Configured robots that ARE present on the wire right now. */
79
+ configured_online: ResolvedRobot[];
80
+ /** Configured robots that are NOT present on the wire right now. */
81
+ configured_offline: ResolvedRobot[];
82
+ /** Detected robots that have no matching config entry — candidates to add. */
83
+ unknown_detected: DetectedRobot[];
84
+ /** Total topics scanned (echoed for diagnostics). */
85
+ total_topics: number;
86
+ }
87
+
88
+ /**
89
+ * Apply the same UUID→robot-no-dashes rewrite as `ros2_publish` uses
90
+ * when normalising a `/<ns>/cmd_vel` target.
91
+ *
92
+ * `""` stays `""` (default namespace). Namespaces that already start
93
+ * with "robot" are returned unchanged.
94
+ */
95
+ export function effectiveCmdVelNamespace(robotNamespace: string): string {
96
+ const ns = robotNamespace.trim();
97
+ if (ns === "") return "";
98
+ if (ns.toLowerCase().startsWith("robot")) return ns;
99
+ return `robot${ns.replace(/-/g, "")}`;
100
+ }
101
+
102
+ /**
103
+ * Find every robot-shaped namespace in a topic list.
104
+ *
105
+ * Detection rule: any `/<seg>/cmd_vel` topic implies a robot with
106
+ * namespace `<seg>`. The unnamespaced `/cmd_vel` topic registers the
107
+ * empty namespace (id=""). Topic count under each namespace is
108
+ * accumulated so consumers can rank by liveness.
109
+ *
110
+ * The returned `DetectedRobot.configuredRobotId` is always `null` here —
111
+ * call `discoverRobots()` (which wraps this function) to annotate with
112
+ * config matches.
113
+ */
114
+ export function detectRobotsFromTopics(topics: TopicInfo[]): DetectedRobot[] {
115
+ const byNs = new Map<string, { cmdVelTopic: string; topicCount: number }>();
116
+
117
+ for (const t of topics) {
118
+ // Match /<seg>/cmd_vel exactly — sub-namespaces would imply a more
119
+ // complex robot graph than this v1 wants to handle.
120
+ const m = t.name.match(/^\/([^/]+)\/cmd_vel$/);
121
+ if (m) {
122
+ const ns = m[1]!;
123
+ if (!byNs.has(ns)) byNs.set(ns, { cmdVelTopic: t.name, topicCount: 0 });
124
+ } else if (t.name === "/cmd_vel" && !byNs.has("")) {
125
+ byNs.set("", { cmdVelTopic: "/cmd_vel", topicCount: 0 });
126
+ }
127
+ }
128
+
129
+ // Count topics under each detected namespace. Empty-namespace robots
130
+ // (default) intentionally don't accumulate counts — every topic in
131
+ // the system would otherwise "belong" to them, which is misleading.
132
+ for (const t of topics) {
133
+ for (const [ns, info] of byNs) {
134
+ if (ns === "") continue;
135
+ if (t.name.startsWith(`/${ns}/`)) {
136
+ info.topicCount += 1;
137
+ }
138
+ }
139
+ }
140
+
141
+ return Array.from(byNs.entries()).map(([id, info]) => ({
142
+ id,
143
+ cmdVelTopic: info.cmdVelTopic,
144
+ topicCount: info.topicCount,
145
+ configuredRobotId: null,
146
+ }));
147
+ }
148
+
149
+ /**
150
+ * Top-level discovery: detect robots on the wire, match against the
151
+ * configured robot list, and classify into online / offline / unknown.
152
+ *
153
+ * Pure function — feed it the topic list and the config, get a result
154
+ * back. No transport access here; adapters wrap this with a call to
155
+ * `transport.listTopics()`.
156
+ */
157
+ export function discoverRobots(
158
+ topics: TopicInfo[],
159
+ config: AgenticROSConfig,
160
+ ): RobotDiscoveryResult {
161
+ const detected = detectRobotsFromTopics(topics);
162
+ const configured = listRobots(config);
163
+
164
+ // Map effective-namespace → configured robot for O(1) lookup.
165
+ const effToCfg = new Map<string, ResolvedRobot>();
166
+ for (const r of configured) {
167
+ effToCfg.set(effectiveCmdVelNamespace(r.namespace), r);
168
+ }
169
+
170
+ const annotated = detected.map((d) => {
171
+ const cfg = effToCfg.get(d.id);
172
+ return cfg
173
+ ? { ...d, configuredRobotId: cfg.id }
174
+ : { ...d, configuredRobotId: null };
175
+ });
176
+
177
+ const detectedIds = new Set(annotated.map((d) => d.id));
178
+ const configured_online = configured.filter((r) =>
179
+ detectedIds.has(effectiveCmdVelNamespace(r.namespace)),
180
+ );
181
+ const configured_offline = configured.filter(
182
+ (r) => !detectedIds.has(effectiveCmdVelNamespace(r.namespace)),
183
+ );
184
+ const unknown_detected = annotated.filter((d) => d.configuredRobotId === null);
185
+
186
+ return {
187
+ detected: annotated,
188
+ configured_online,
189
+ configured_offline,
190
+ unknown_detected,
191
+ total_topics: topics.length,
192
+ };
193
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `findRobotsFor` — Phase 1.e capability-aware robot filter.
3
+ *
4
+ * Given a (capability?, kind?, online?) query, returns the configured
5
+ * robots that match, ranked best-first. This is the resolver behind the
6
+ * `ros2_find_robots_for` MCP tool — adapters call it from `tools.ts`
7
+ * with the live online-set computed from a topic scan (or `undefined`
8
+ * when `online` isn't being filtered, so we don't pay for the scan).
9
+ *
10
+ * Filter semantics:
11
+ * - `capability` — keep robots whose per-robot allowlist
12
+ * (`config.robots[i].capabilities`) includes the verb, OR (when no
13
+ * allowlist is set) whose gateway's global capability registry
14
+ * includes it. The global path is the common case — every robot
15
+ * today exposes the same builtin + skill-declared capabilities. The
16
+ * per-robot allowlist exists for heterogeneous fleets where (e.g.)
17
+ * only one robot has the `arm_skill` loaded.
18
+ * - `kind` — exact match on `robot.kind` (case-insensitive). Use
19
+ * "amr" / "arm" / "drone" / "rover" by convention but any string is
20
+ * accepted by the schema.
21
+ * - `online` — `true` keeps only robots whose id is in `onlineIds`;
22
+ * `false` keeps only robots NOT in `onlineIds`; `undefined` skips
23
+ * this filter entirely. The adapter is responsible for populating
24
+ * `onlineIds` from the live topic graph (typically via
25
+ * `discoverRobots()`) before calling this — core doesn't touch the
26
+ * transport.
27
+ *
28
+ * Ranking (highest score first):
29
+ * +2 online (when query.online is true)
30
+ * +1 per-robot capabilities allowlist included the requested verb
31
+ * (an explicit "I support this" beats a global-registry inference)
32
+ * +0 default tier
33
+ *
34
+ * Ties broken by config declaration order (stable).
35
+ *
36
+ * Why not a full LLM-style scoring function? Phase 1.e's goal is
37
+ * deterministic and explainable — an agent calling `find_robots_for`
38
+ * wants a list it can plan against, not a relevance heuristic. The
39
+ * +1/+2 deltas are enough to surface a perfect match above an
40
+ * inherited one without inventing fuzzy matching.
41
+ */
42
+
43
+ import type { AgenticROSConfig } from "./config.js";
44
+ import { listAllCapabilities, type Capability } from "./capabilities.js";
45
+ import { listRobots, type ResolvedRobot } from "./robots.js";
46
+
47
+ /** Query input to `findRobotsFor`. All fields optional → returns every robot. */
48
+ export interface FindRobotsForQuery {
49
+ /** Capability id (e.g. `follow_person`, `find_object`). Case-sensitive — match the registry. */
50
+ capability?: string;
51
+ /** Robot kind (e.g. `amr`, `arm`, `drone`). Case-insensitive. */
52
+ kind?: string;
53
+ /** When set, restrict to online (true) or offline (false) robots. */
54
+ online?: boolean;
55
+ }
56
+
57
+ /** One entry in the find-robots-for result. */
58
+ export interface FindRobotsForMatch {
59
+ robot: ResolvedRobot;
60
+ /** True when the capability filter was satisfied by an explicit per-robot allowlist. */
61
+ matched_capability_explicitly: boolean;
62
+ /**
63
+ * True when this robot is in the caller-supplied online set, false when
64
+ * not, and `null` when the caller didn't provide an online set at all
65
+ * (so we can't say either way).
66
+ */
67
+ online: boolean | null;
68
+ /** Sort key — higher = better match. See module header for the formula. */
69
+ score: number;
70
+ }
71
+
72
+ /** Result envelope. */
73
+ export interface FindRobotsForResult {
74
+ query: FindRobotsForQuery;
75
+ total: number;
76
+ /** Best-matching robots first. */
77
+ robots: FindRobotsForMatch[];
78
+ }
79
+
80
+ /**
81
+ * Run the filter+rank against the configured fleet.
82
+ *
83
+ * `onlineIds` is optional. When omitted AND `query.online` is set, this
84
+ * throws — the adapter MUST resolve the live set before calling this so
85
+ * the core stays transport-agnostic.
86
+ *
87
+ * The match list is stable across calls with the same input (no
88
+ * non-determinism, no time-of-day effects).
89
+ */
90
+ export function findRobotsFor(
91
+ config: AgenticROSConfig,
92
+ query: FindRobotsForQuery,
93
+ onlineIds?: ReadonlySet<string>,
94
+ ): FindRobotsForResult {
95
+ if (query.online !== undefined && onlineIds === undefined) {
96
+ throw new Error(
97
+ "findRobotsFor: query.online was set but onlineIds was not provided. " +
98
+ "Call discoverRobots() first and pass configured_online ids.",
99
+ );
100
+ }
101
+
102
+ // Resolve the global capability registry once — used as the fallback
103
+ // when a robot doesn't declare its own per-robot allowlist.
104
+ const globalCapIds: ReadonlySet<string> = new Set(
105
+ listAllCapabilities(config).map((c: Capability) => c.id),
106
+ );
107
+
108
+ const wantedKind = query.kind?.trim().toLowerCase();
109
+ const wantedCap = query.capability?.trim();
110
+
111
+ const fleet = listRobots(config);
112
+ const matches: FindRobotsForMatch[] = [];
113
+
114
+ for (const robot of fleet) {
115
+ // kind filter
116
+ if (wantedKind && robot.kind.toLowerCase() !== wantedKind) continue;
117
+
118
+ // capability filter
119
+ let matchedExplicitly = false;
120
+ if (wantedCap) {
121
+ const allowlist = robot.capabilities;
122
+ if (allowlist) {
123
+ if (!allowlist.includes(wantedCap)) continue;
124
+ matchedExplicitly = true;
125
+ } else {
126
+ // No per-robot allowlist → fall back to the gateway-wide
127
+ // registry. This is the common case today.
128
+ if (!globalCapIds.has(wantedCap)) continue;
129
+ }
130
+ }
131
+
132
+ // online filter
133
+ let isOnline: boolean | null = null;
134
+ if (onlineIds) {
135
+ isOnline = onlineIds.has(robot.id);
136
+ if (query.online === true && !isOnline) continue;
137
+ if (query.online === false && isOnline) continue;
138
+ }
139
+
140
+ let score = 0;
141
+ if (matchedExplicitly) score += 1;
142
+ if (query.online === true && isOnline) score += 2;
143
+ // Reward online robots even when the user didn't filter for it —
144
+ // an agent picking a robot for an action almost always wants the
145
+ // one currently reachable.
146
+ if (query.online === undefined && isOnline === true) score += 1;
147
+
148
+ matches.push({ robot, matched_capability_explicitly: matchedExplicitly, online: isOnline, score });
149
+ }
150
+
151
+ // Stable sort: higher score first, tie-broken by original config
152
+ // order (which is what listRobots() returned).
153
+ matches.sort((a, b) => b.score - a.score);
154
+
155
+ return {
156
+ query: {
157
+ ...(wantedCap ? { capability: wantedCap } : {}),
158
+ ...(wantedKind ? { kind: wantedKind } : {}),
159
+ ...(query.online !== undefined ? { online: query.online } : {}),
160
+ },
161
+ total: matches.length,
162
+ robots: matches,
163
+ };
164
+ }
@@ -2,7 +2,7 @@
2
2
  * @agenticros/core — Platform-agnostic ROS2 transport, config, and utilities.
3
3
  */
4
4
 
5
- export type { AgenticROSConfig } from "./config.js";
5
+ export type { AgenticROSConfig, RobotTransportOverride } from "./config.js";
6
6
  export {
7
7
  AgenticROSConfigSchema,
8
8
  parseConfig,
@@ -52,3 +52,69 @@ export type {
52
52
  RecallInput,
53
53
  ForgetInput,
54
54
  } from "./memory/index.js";
55
+
56
+ export {
57
+ BUILTIN_CAPABILITIES,
58
+ readSkillCapabilities,
59
+ listAllCapabilities,
60
+ } from "./capabilities.js";
61
+ export type {
62
+ Capability,
63
+ CapabilityField,
64
+ CapabilityImplementation,
65
+ CapabilitySource,
66
+ } from "./capabilities.js";
67
+
68
+ export { runMission } from "./mission.js";
69
+ export type {
70
+ Mission,
71
+ MissionStep,
72
+ MissionResult,
73
+ MissionStepResult,
74
+ MissionToolDispatcher,
75
+ CapabilityToolBinding,
76
+ CapabilityToolBindings,
77
+ MissionCancellationToken,
78
+ MissionTranscriptEntry,
79
+ MissionTranscriptSink,
80
+ RunMissionOptions,
81
+ } from "./mission.js";
82
+
83
+ export {
84
+ MissionRegistry,
85
+ generateMissionId,
86
+ missionTranscriptNamespace,
87
+ } from "./mission-registry.js";
88
+ export type { MissionRegistryEntry } from "./mission-registry.js";
89
+
90
+ export { createMemoryTranscriptSink } from "./mission-transcript-sink.js";
91
+
92
+ export { compileGoalToMission } from "./planner/index.js";
93
+ export type { PlannerResult, PlannerCandidate } from "./planner/index.js";
94
+
95
+ export {
96
+ listRobots,
97
+ resolveRobot,
98
+ resolveRobotFromArgs,
99
+ getActiveRobotId,
100
+ getTransportConfigForRobot,
101
+ hasRobotTransportOverride,
102
+ } from "./robots.js";
103
+ export type { ResolvedRobot, RobotSensors } from "./robots.js";
104
+
105
+ export {
106
+ detectRobotsFromTopics,
107
+ discoverRobots,
108
+ effectiveCmdVelNamespace,
109
+ } from "./discovery.js";
110
+ export type { DetectedRobot, RobotDiscoveryResult } from "./discovery.js";
111
+
112
+ export { findRobotsFor } from "./find-robots-for.js";
113
+ export type {
114
+ FindRobotsForQuery,
115
+ FindRobotsForMatch,
116
+ FindRobotsForResult,
117
+ } from "./find-robots-for.js";
118
+
119
+ export { TransportPool, TRANSPORT_POOL_GLOBAL_KEY } from "./transport-pool.js";
120
+ export type { TransportFactory } from "./transport-pool.js";
@@ -0,0 +1,142 @@
1
+ /**
2
+ * MissionRegistry — Phase 1.f in-process tracker for active missions.
3
+ *
4
+ * Each adapter (Claude Code MCP server, OpenClaw plugin, Gemini CLI)
5
+ * keeps a module-level registry. When `run_mission` starts, the adapter
6
+ * registers a fresh cancellation token under a unique `mission_id` and
7
+ * returns the id in the tool response. A sibling `mission_cancel` tool
8
+ * call then looks up the token by id and flips `cancelled = true` — the
9
+ * mission runner picks that up at the next step boundary and stops
10
+ * gracefully.
11
+ *
12
+ * Lifetime semantics:
13
+ * - `register()` returns the cancellation token AND a `dispose()`
14
+ * callback the caller is expected to invoke in a `finally` block
15
+ * once the mission ends (regardless of outcome). Disposal removes
16
+ * the entry so a subsequent cancel doesn't silently target a
17
+ * completed mission and the in-memory map stays small.
18
+ * - `cancel()` is idempotent — calling it on a missing id returns
19
+ * `{ found: false }`; calling it twice on the same id is a no-op
20
+ * after the first.
21
+ *
22
+ * Why in-process (not cross-process)? Cancellation is fundamentally
23
+ * "stop the dispatcher I'm running"; that dispatcher is local to the
24
+ * adapter process. Cross-agent coordination is Phase 4. The transcript
25
+ * subsystem (memory) handles the "different agent reads what I did"
26
+ * story orthogonally.
27
+ */
28
+
29
+ import type { MissionCancellationToken } from "./mission.js";
30
+
31
+ /** One registered mission. */
32
+ export interface MissionRegistryEntry {
33
+ mission_id: string;
34
+ /** Adapter-supplied label for diagnostics (e.g. mission.name). */
35
+ name?: string;
36
+ /** ms since epoch when the mission was registered. */
37
+ started_at: number;
38
+ /** The token the mission runner reads each step. */
39
+ cancellation: MissionCancellationToken;
40
+ }
41
+
42
+ /**
43
+ * Generate a stable, URL-safe mission identifier.
44
+ *
45
+ * Uses `crypto.randomUUID()` when available (Node 14.17+, Bun, modern
46
+ * browsers); falls back to a Math.random hex string with a `mns_` prefix.
47
+ * Either way the id is opaque to downstream consumers — they just echo
48
+ * it back to `mission_cancel` / `memory_recall`.
49
+ */
50
+ export function generateMissionId(): string {
51
+ const g = globalThis as { crypto?: { randomUUID?: () => string } };
52
+ if (g.crypto?.randomUUID) return `mn_${g.crypto.randomUUID()}`;
53
+ const a = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, "0");
54
+ const b = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, "0");
55
+ const c = Date.now().toString(16);
56
+ return `mn_${a}${b}${c}`;
57
+ }
58
+
59
+ /**
60
+ * Compute the canonical memory namespace for a mission's transcripts.
61
+ *
62
+ * The convention is `mission:<id>` so a downstream agent can recall
63
+ * every step the first agent ran via
64
+ * `memory_recall({ namespace: "mission:<id>", query: "" })`. This lives
65
+ * here (not in the memory module) because the adapter's transcript
66
+ * sink uses it and we want exactly one source of truth.
67
+ */
68
+ export function missionTranscriptNamespace(missionId: string): string {
69
+ return `mission:${missionId}`;
70
+ }
71
+
72
+ export class MissionRegistry {
73
+ private readonly entries = new Map<string, MissionRegistryEntry>();
74
+
75
+ /**
76
+ * Register a fresh mission, returning the entry (with the
77
+ * cancellation token to hand to `runMission`) and a `dispose` hook
78
+ * to invoke when the mission ends. The id is caller-supplied so
79
+ * adapters can echo it in the tool response before this point.
80
+ */
81
+ register(missionId: string, opts?: { name?: string }): {
82
+ entry: MissionRegistryEntry;
83
+ dispose: () => void;
84
+ } {
85
+ const entry: MissionRegistryEntry = {
86
+ mission_id: missionId,
87
+ name: opts?.name,
88
+ started_at: Date.now(),
89
+ cancellation: { cancelled: false },
90
+ };
91
+ this.entries.set(missionId, entry);
92
+ return {
93
+ entry,
94
+ dispose: () => {
95
+ // Only remove if we're disposing the same entry — guards against
96
+ // a race where the same id is re-registered between dispose calls
97
+ // (unlikely but cheap to defend against).
98
+ if (this.entries.get(missionId) === entry) this.entries.delete(missionId);
99
+ },
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Flip the cancellation token for the named mission.
105
+ *
106
+ * Returns `{ found: true }` when the mission existed (whether it was
107
+ * already cancelled or not), `{ found: false }` when the id is
108
+ * unknown (e.g. mission already finished + disposed).
109
+ */
110
+ cancel(missionId: string, reason?: string): { found: boolean; alreadyCancelled: boolean } {
111
+ const entry = this.entries.get(missionId);
112
+ if (!entry) return { found: false, alreadyCancelled: false };
113
+ const alreadyCancelled = entry.cancellation.cancelled === true;
114
+ entry.cancellation.cancelled = true;
115
+ if (reason !== undefined) entry.cancellation.reason = reason;
116
+ return { found: true, alreadyCancelled };
117
+ }
118
+
119
+ /** True when the named mission is currently registered. */
120
+ has(missionId: string): boolean {
121
+ return this.entries.has(missionId);
122
+ }
123
+
124
+ /**
125
+ * Snapshot of the active mission set (for diagnostics / mission_list
126
+ * tools down the road). Returns a shallow copy so callers can't
127
+ * mutate internal state.
128
+ */
129
+ list(): MissionRegistryEntry[] {
130
+ return [...this.entries.values()].map((e) => ({
131
+ mission_id: e.mission_id,
132
+ name: e.name,
133
+ started_at: e.started_at,
134
+ cancellation: { ...e.cancellation },
135
+ }));
136
+ }
137
+
138
+ /** Test-only: drop every registered mission. */
139
+ _clear(): void {
140
+ this.entries.clear();
141
+ }
142
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Memory-backed mission transcript sink — Phase 1.f wiring.
3
+ *
4
+ * `createMemoryTranscriptSink(memory, missionId)` returns a
5
+ * `MissionTranscriptSink` the adapter hands to `runMission`. Each
6
+ * step's `MissionTranscriptEntry` is serialised to JSON and persisted
7
+ * via the shared `MemoryProvider` under the canonical
8
+ * `mission:<id>` namespace.
9
+ *
10
+ * Why JSON content (and not the structured `MemoryRecord` fields):
11
+ * `MemoryProvider.remember` only takes `content: string` (plus
12
+ * optional tags/path). We pack the full step snapshot into `content`
13
+ * so a downstream agent can `JSON.parse(record.content)` after
14
+ * `memory_recall(namespace="mission:<id>")`. Tags are used as
15
+ * coarse-grained filters (e.g. "step:ok" vs "step:error") for
16
+ * future status queries.
17
+ *
18
+ * The returned sink is best-effort by contract — `runMission` already
19
+ * swallows thrown errors, but we also defensively catch the await so a
20
+ * mem0 outage never propagates back into the run loop.
21
+ */
22
+
23
+ import type { MemoryProvider } from "./memory/index.js";
24
+ import type { MissionTranscriptSink, MissionTranscriptEntry } from "./mission.js";
25
+ import { missionTranscriptNamespace } from "./mission-registry.js";
26
+
27
+ /**
28
+ * Build a transcript sink that writes each step's entry to memory.
29
+ *
30
+ * @param memory Active memory provider (caller has already
31
+ * confirmed `config.memory.enabled === true`).
32
+ * @param missionId Mission identifier. Surfaced as the namespace
33
+ * and also embedded in the content for grep-ability.
34
+ */
35
+ export function createMemoryTranscriptSink(
36
+ memory: MemoryProvider,
37
+ missionId: string,
38
+ ): MissionTranscriptSink {
39
+ const namespace = missionTranscriptNamespace(missionId);
40
+ return async (entry: MissionTranscriptEntry): Promise<void> => {
41
+ try {
42
+ const content = JSON.stringify({
43
+ // Echo mission_id in the payload too — recall() returns the
44
+ // content blob; mission_id in the namespace might be lost if
45
+ // the caller scopes their recall differently.
46
+ mission_id: entry.mission_id || missionId,
47
+ mission_name: entry.mission_name,
48
+ adapter: entry.adapter,
49
+ robot_id: entry.robot_id,
50
+ started_at: entry.started_at,
51
+ step_index: entry.step_index,
52
+ step_total: entry.step_total,
53
+ step: {
54
+ id: entry.result.id,
55
+ capability: entry.result.capability,
56
+ status: entry.result.status,
57
+ inputs: entry.result.inputs,
58
+ outputs: entry.result.outputs,
59
+ message: entry.result.message,
60
+ error: entry.result.error,
61
+ duration_ms: entry.result.duration_ms,
62
+ },
63
+ });
64
+ // Tags are deliberately stable + greppable so a future
65
+ // `memory_recall(tags=…)` can filter to "every cancelled step"
66
+ // or "every step that touched capability=drive_base".
67
+ await memory.remember({
68
+ namespace,
69
+ content,
70
+ tags: [
71
+ "mission_transcript",
72
+ `step:${entry.result.status}`,
73
+ `capability:${entry.result.capability}`,
74
+ ],
75
+ path: `${missionId}/${String(entry.step_index).padStart(3, "0")}-${entry.result.id}`,
76
+ });
77
+ } catch {
78
+ // Best-effort: transcript loss must NEVER abort the mission.
79
+ }
80
+ };
81
+ }