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/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 { room: exactRoom, joinedExistingRoom: true };
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.`, {