talking-stick 0.1.3 → 0.2.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/README.md +32 -3
- package/dist/cli/event-stream.js +124 -0
- package/dist/cli/msg-commands.js +81 -0
- package/dist/cli/output.js +3 -1
- package/dist/cli/registry.js +11 -1
- package/dist/cli/room-commands.js +18 -3
- package/dist/commands.js +15 -0
- package/dist/config.js +3 -0
- package/dist/db.js +7 -0
- package/dist/mcp-server.js +32 -0
- package/dist/service.js +166 -5
- package/docs/plans/out-of-band-signaling-implementation.md +854 -0
- package/docs/plans/out-of-band-signaling.md +255 -176
- package/docs/receive-consumer-contract.md +30 -0
- package/docs/releases/0.1.4.md +68 -0
- package/docs/releases/0.2.0.md +85 -0
- package/docs/talking-stick-plan.md +1 -1
- package/package.json +1 -1
- package/skills/talking-stick/SKILL.md +25 -3
package/dist/service.js
CHANGED
|
@@ -7,6 +7,16 @@ import { openDatabase, withImmediateTransaction } from "./db.js";
|
|
|
7
7
|
import { ProtocolError } from "./errors.js";
|
|
8
8
|
import { createSystemProcessInspector } from "./process-utils.js";
|
|
9
9
|
const MAX_NOTE_BODY_BYTES = 16 * 1024;
|
|
10
|
+
const MAX_MESSAGE_BODY_BYTES = 4096;
|
|
11
|
+
const KNOWN_EVENT_TYPES = [
|
|
12
|
+
"claim",
|
|
13
|
+
"release",
|
|
14
|
+
"pass",
|
|
15
|
+
"takeover",
|
|
16
|
+
"close",
|
|
17
|
+
"kick",
|
|
18
|
+
"message_sent"
|
|
19
|
+
];
|
|
10
20
|
export class TalkingStickService {
|
|
11
21
|
db;
|
|
12
22
|
policy;
|
|
@@ -442,6 +452,95 @@ export class TalkingStickService {
|
|
|
442
452
|
.all(input.room_id, afterEventSeq, limit)
|
|
443
453
|
.map((row) => this.mapEvent(row));
|
|
444
454
|
}
|
|
455
|
+
sendMessage(input) {
|
|
456
|
+
assertNonEmpty(input.agent_id, "agent_id");
|
|
457
|
+
assertNonEmpty(input.room_id, "room_id");
|
|
458
|
+
const body = input.body ?? "";
|
|
459
|
+
if (body.length === 0) {
|
|
460
|
+
throw new ProtocolError("invalid_body", "Message body must not be empty.");
|
|
461
|
+
}
|
|
462
|
+
const byteLength = Buffer.byteLength(body, "utf8");
|
|
463
|
+
if (byteLength > MAX_MESSAGE_BODY_BYTES) {
|
|
464
|
+
throw new ProtocolError("message_too_large", `Message body exceeds ${MAX_MESSAGE_BODY_BYTES} bytes.`, { supplied: byteLength });
|
|
465
|
+
}
|
|
466
|
+
const deliveryHint = input.delivery_hint ?? "normal";
|
|
467
|
+
if (deliveryHint !== "normal" && deliveryHint !== "interrupt") {
|
|
468
|
+
throw new ProtocolError("invalid_delivery_hint", "delivery_hint must be 'normal' or 'interrupt'.");
|
|
469
|
+
}
|
|
470
|
+
const now = this.now();
|
|
471
|
+
const timestamp = now.toISOString();
|
|
472
|
+
this.purgeExpiredIdleRooms(now);
|
|
473
|
+
return withImmediateTransaction(this.db, () => {
|
|
474
|
+
const room = this.requireRoom(input.room_id);
|
|
475
|
+
if (room.state === "closed") {
|
|
476
|
+
throw new ProtocolError("room_closed", "Messages cannot be sent to a closed room.", { room_id: input.room_id });
|
|
477
|
+
}
|
|
478
|
+
this.touchMember(input.room_id, input.agent_id, timestamp);
|
|
479
|
+
if (input.to_agent_id) {
|
|
480
|
+
const target = this.getMember(input.room_id, input.to_agent_id);
|
|
481
|
+
if (!target) {
|
|
482
|
+
throw new ProtocolError("unknown_recipient", "to_agent_id is not a member of this room.", { to_agent_id: input.to_agent_id });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const eventSeq = this.appendEvent({
|
|
486
|
+
room_id: input.room_id,
|
|
487
|
+
turn_id: room.turn_id,
|
|
488
|
+
event_type: "message_sent",
|
|
489
|
+
from_agent_id: input.agent_id,
|
|
490
|
+
to_agent_id: input.to_agent_id ?? null,
|
|
491
|
+
handoff: null,
|
|
492
|
+
reason: null,
|
|
493
|
+
created_at: timestamp,
|
|
494
|
+
payload: { body, delivery_hint: deliveryHint }
|
|
495
|
+
});
|
|
496
|
+
const row = this.db
|
|
497
|
+
.prepare("SELECT event_id FROM room_events WHERE event_seq = ?")
|
|
498
|
+
.get(eventSeq);
|
|
499
|
+
return {
|
|
500
|
+
event_seq: eventSeq,
|
|
501
|
+
event_id: row?.event_id ?? "",
|
|
502
|
+
created_at: timestamp
|
|
503
|
+
};
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
async waitForEvents(input) {
|
|
507
|
+
assertNonEmpty(input.room_id, "room_id");
|
|
508
|
+
this.requireRoom(input.room_id);
|
|
509
|
+
const targetFilter = input.target_agent_id ?? "self";
|
|
510
|
+
if (targetFilter === "self" && !input.agent_id) {
|
|
511
|
+
throw new ProtocolError("agent_id_required", "agent_id is required when target_agent_id is 'self'.");
|
|
512
|
+
}
|
|
513
|
+
const eventTypes = normalizeEventTypeFilter(input.event_type);
|
|
514
|
+
const afterEventSeq = input.after_event_seq ?? 0;
|
|
515
|
+
const maxWaitMs = Math.min(Math.max(input.max_wait_ms ?? this.policy.waitForEventsMaxWaitMs, 0), this.policy.waitForEventsMaxWaitMs);
|
|
516
|
+
const deadline = Date.now() + maxWaitMs;
|
|
517
|
+
while (true) {
|
|
518
|
+
const events = this.queryEvents({
|
|
519
|
+
room_id: input.room_id,
|
|
520
|
+
after_event_seq: afterEventSeq,
|
|
521
|
+
event_types: eventTypes,
|
|
522
|
+
target: targetFilter,
|
|
523
|
+
caller_agent_id: input.agent_id ?? null,
|
|
524
|
+
from_agent_id: input.from_agent_id ?? null,
|
|
525
|
+
limit: this.policy.waitForEventsBatchLimit
|
|
526
|
+
});
|
|
527
|
+
if (events.length > 0 || Date.now() >= deadline) {
|
|
528
|
+
const lastSeq = events.length > 0
|
|
529
|
+
? events[events.length - 1].event_seq
|
|
530
|
+
: afterEventSeq;
|
|
531
|
+
return { events, cursor_event_seq: lastSeq };
|
|
532
|
+
}
|
|
533
|
+
const remainingMs = deadline - Date.now();
|
|
534
|
+
await sleep(Math.min(this.policy.waitForEventsPollMs, remainingMs));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
getLatestEventSeq(input) {
|
|
538
|
+
assertNonEmpty(input.room_id, "room_id");
|
|
539
|
+
this.requireRoom(input.room_id);
|
|
540
|
+
return (this.db
|
|
541
|
+
.prepare("SELECT MAX(event_seq) AS event_seq FROM room_events WHERE room_id = ?")
|
|
542
|
+
.get(input.room_id)?.event_seq ?? 0);
|
|
543
|
+
}
|
|
445
544
|
addNote(input) {
|
|
446
545
|
assertNonEmpty(input.agent_id, "agent_id");
|
|
447
546
|
assertNonEmpty(input.room_id, "room_id");
|
|
@@ -682,7 +781,11 @@ export class TalkingStickService {
|
|
|
682
781
|
if (forceNew) {
|
|
683
782
|
const exactRoom = this.findRoomByCanonicalPath(resolved.canonical_context_path);
|
|
684
783
|
if (exactRoom) {
|
|
685
|
-
return {
|
|
784
|
+
return {
|
|
785
|
+
room: exactRoom,
|
|
786
|
+
joinedExistingRoom: true,
|
|
787
|
+
warning: `force_new had no effect: a room already exists at ${exactRoom.canonical_path}. force_new only creates a nested room when an ancestor room exists; same-path duplicates are not supported. To get a fresh room for a separate topic, join a distinct subpath.`
|
|
788
|
+
};
|
|
686
789
|
}
|
|
687
790
|
return {
|
|
688
791
|
room: this.createRoom(resolved.canonical_context_path, timestamp),
|
|
@@ -979,13 +1082,52 @@ export class TalkingStickService {
|
|
|
979
1082
|
to_agent_id,
|
|
980
1083
|
handoff_json,
|
|
981
1084
|
reason,
|
|
982
|
-
created_at
|
|
1085
|
+
created_at,
|
|
1086
|
+
payload_json
|
|
983
1087
|
)
|
|
984
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1088
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
985
1089
|
`)
|
|
986
|
-
.run(randomUUID(), input.room_id, input.turn_id, input.event_type, input.from_agent_id, input.to_agent_id, input.handoff ? JSON.stringify(input.handoff) : null, input.reason, input.created_at);
|
|
1090
|
+
.run(randomUUID(), input.room_id, input.turn_id, input.event_type, input.from_agent_id, input.to_agent_id, input.handoff ? JSON.stringify(input.handoff) : null, input.reason, input.created_at, input.payload ? JSON.stringify(input.payload) : null);
|
|
987
1091
|
return Number(result.lastInsertRowid);
|
|
988
1092
|
}
|
|
1093
|
+
queryEvents(input) {
|
|
1094
|
+
const clauses = ["room_id = ?", "event_seq > ?"];
|
|
1095
|
+
const params = [input.room_id, input.after_event_seq];
|
|
1096
|
+
if (input.event_types) {
|
|
1097
|
+
clauses.push(`event_type IN (${input.event_types.map(() => "?").join(", ")})`);
|
|
1098
|
+
params.push(...input.event_types);
|
|
1099
|
+
}
|
|
1100
|
+
if (input.target === "self") {
|
|
1101
|
+
if (!input.caller_agent_id) {
|
|
1102
|
+
throw new ProtocolError("agent_id_required", "agent_id is required when target_agent_id is 'self'.");
|
|
1103
|
+
}
|
|
1104
|
+
clauses.push(`(
|
|
1105
|
+
(event_type = 'message_sent' AND (to_agent_id = ? OR (to_agent_id IS NULL AND from_agent_id != ?)))
|
|
1106
|
+
OR
|
|
1107
|
+
(event_type != 'message_sent' AND (to_agent_id = ? OR from_agent_id = ?))
|
|
1108
|
+
)`);
|
|
1109
|
+
params.push(input.caller_agent_id, input.caller_agent_id, input.caller_agent_id, input.caller_agent_id);
|
|
1110
|
+
}
|
|
1111
|
+
else if (input.target !== "any") {
|
|
1112
|
+
clauses.push("to_agent_id = ?");
|
|
1113
|
+
params.push(input.target);
|
|
1114
|
+
}
|
|
1115
|
+
if (input.from_agent_id) {
|
|
1116
|
+
clauses.push("from_agent_id = ?");
|
|
1117
|
+
params.push(input.from_agent_id);
|
|
1118
|
+
}
|
|
1119
|
+
params.push(Math.min(Math.max(input.limit, 1), 500));
|
|
1120
|
+
return this.db
|
|
1121
|
+
.prepare(`
|
|
1122
|
+
SELECT *
|
|
1123
|
+
FROM room_events
|
|
1124
|
+
WHERE ${clauses.join(" AND ")}
|
|
1125
|
+
ORDER BY event_seq
|
|
1126
|
+
LIMIT ?
|
|
1127
|
+
`)
|
|
1128
|
+
.all(...params)
|
|
1129
|
+
.map((row) => this.mapEvent(row));
|
|
1130
|
+
}
|
|
989
1131
|
getRoomRow(roomId) {
|
|
990
1132
|
return this.db
|
|
991
1133
|
.prepare("SELECT * FROM path_rooms WHERE room_id = ?")
|
|
@@ -1302,6 +1444,9 @@ export class TalkingStickService {
|
|
|
1302
1444
|
};
|
|
1303
1445
|
}
|
|
1304
1446
|
mapEvent(row) {
|
|
1447
|
+
const payload = row.event_type === "message_sent" && row.payload_json
|
|
1448
|
+
? JSON.parse(row.payload_json)
|
|
1449
|
+
: null;
|
|
1305
1450
|
return {
|
|
1306
1451
|
event_seq: row.event_seq,
|
|
1307
1452
|
event_id: row.event_id,
|
|
@@ -1314,7 +1459,8 @@ export class TalkingStickService {
|
|
|
1314
1459
|
? JSON.parse(row.handoff_json)
|
|
1315
1460
|
: null,
|
|
1316
1461
|
reason: row.reason,
|
|
1317
|
-
created_at: row.created_at
|
|
1462
|
+
created_at: row.created_at,
|
|
1463
|
+
payload
|
|
1318
1464
|
};
|
|
1319
1465
|
}
|
|
1320
1466
|
}
|
|
@@ -1445,6 +1591,21 @@ function validateHandoff(handoff) {
|
|
|
1445
1591
|
throw new ProtocolError("invalid_handoff", "handoff.artifacts must be an array when provided.", { field: "artifacts" });
|
|
1446
1592
|
}
|
|
1447
1593
|
}
|
|
1594
|
+
function normalizeEventTypeFilter(filter) {
|
|
1595
|
+
if (filter === undefined) {
|
|
1596
|
+
return null;
|
|
1597
|
+
}
|
|
1598
|
+
const values = Array.isArray(filter) ? filter : [filter];
|
|
1599
|
+
if (values.length === 0) {
|
|
1600
|
+
throw new ProtocolError("invalid_event_type_filter", "event_type filter must not be empty.");
|
|
1601
|
+
}
|
|
1602
|
+
for (const value of values) {
|
|
1603
|
+
if (!KNOWN_EVENT_TYPES.includes(value)) {
|
|
1604
|
+
throw new ProtocolError("invalid_event_type_filter", `Unsupported event_type: ${value}.`);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return values;
|
|
1608
|
+
}
|
|
1448
1609
|
function assertNonEmpty(value, field) {
|
|
1449
1610
|
if (!value || !value.trim()) {
|
|
1450
1611
|
throw new ProtocolError("invalid_input", `${field} must be non-empty.`, {
|