talking-stick 0.1.4 → 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/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");
@@ -983,13 +1082,52 @@ export class TalkingStickService {
983
1082
  to_agent_id,
984
1083
  handoff_json,
985
1084
  reason,
986
- created_at
1085
+ created_at,
1086
+ payload_json
987
1087
  )
988
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1088
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
989
1089
  `)
990
- .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);
991
1091
  return Number(result.lastInsertRowid);
992
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
+ }
993
1131
  getRoomRow(roomId) {
994
1132
  return this.db
995
1133
  .prepare("SELECT * FROM path_rooms WHERE room_id = ?")
@@ -1306,6 +1444,9 @@ export class TalkingStickService {
1306
1444
  };
1307
1445
  }
1308
1446
  mapEvent(row) {
1447
+ const payload = row.event_type === "message_sent" && row.payload_json
1448
+ ? JSON.parse(row.payload_json)
1449
+ : null;
1309
1450
  return {
1310
1451
  event_seq: row.event_seq,
1311
1452
  event_id: row.event_id,
@@ -1318,7 +1459,8 @@ export class TalkingStickService {
1318
1459
  ? JSON.parse(row.handoff_json)
1319
1460
  : null,
1320
1461
  reason: row.reason,
1321
- created_at: row.created_at
1462
+ created_at: row.created_at,
1463
+ payload
1322
1464
  };
1323
1465
  }
1324
1466
  }
@@ -1449,6 +1591,21 @@ function validateHandoff(handoff) {
1449
1591
  throw new ProtocolError("invalid_handoff", "handoff.artifacts must be an array when provided.", { field: "artifacts" });
1450
1592
  }
1451
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
+ }
1452
1609
  function assertNonEmpty(value, field) {
1453
1610
  if (!value || !value.trim()) {
1454
1611
  throw new ProtocolError("invalid_input", `${field} must be non-empty.`, {
@@ -0,0 +1,135 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { resolveDataDir } from "./config.js";
5
+ import { FileAuditLog, defaultAuditLogPath } from "./install-audit.js";
6
+ import { removeStaleMcpRegistrations } from "./install-migration.js";
7
+ export const UPDATE_MIGRATION_STATE_FILE = "update-migrations-state.json";
8
+ export async function runStaleMcpCleanup(options) {
9
+ const packageVersionTo = options.packageVersionTo ?? options.packageVersion ?? readPackageVersion();
10
+ const dataDir = options.dataDir ?? resolveMigrationDataDir(options.installOptions);
11
+ const statePath = resolveUpdateMigrationStatePath(dataDir);
12
+ const auditPath = defaultAuditLogPath(dataDir);
13
+ const audit = options.audit ?? new FileAuditLog(auditPath);
14
+ const results = await removeStaleMcpRegistrations({
15
+ harnesses: options.harnesses ?? "all",
16
+ reason: options.reason,
17
+ packageVersionFrom: options.packageVersionFrom,
18
+ packageVersionTo,
19
+ audit,
20
+ installOptions: options.installOptions
21
+ });
22
+ if (options.updateState !== false && !results.some((result) => result.action === "failed")) {
23
+ writeUpdateMigrationState(statePath, {
24
+ mcp_cleanup_version: packageVersionTo,
25
+ updated_at: new Date().toISOString()
26
+ });
27
+ }
28
+ return {
29
+ status: "ran",
30
+ packageVersionFrom: options.packageVersionFrom,
31
+ packageVersionTo,
32
+ statePath,
33
+ auditPath,
34
+ results
35
+ };
36
+ }
37
+ export async function runFirstRunMcpMigration(options = {}) {
38
+ const packageVersion = options.packageVersion ?? readPackageVersion();
39
+ const dataDir = options.dataDir ?? resolveMigrationDataDir(options.installOptions);
40
+ const statePath = resolveUpdateMigrationStatePath(dataDir);
41
+ const auditPath = defaultAuditLogPath(dataDir);
42
+ const state = readUpdateMigrationState(statePath);
43
+ if (state.mcp_cleanup_version === packageVersion) {
44
+ return {
45
+ status: "current",
46
+ packageVersion,
47
+ statePath,
48
+ auditPath,
49
+ results: []
50
+ };
51
+ }
52
+ return runStaleMcpCleanup({
53
+ harnesses: "all",
54
+ reason: "first-run",
55
+ packageVersionFrom: state.mcp_cleanup_version,
56
+ packageVersionTo: packageVersion,
57
+ dataDir,
58
+ audit: options.audit,
59
+ installOptions: options.installOptions
60
+ });
61
+ }
62
+ export function resolveUpdateMigrationStatePath(dataDir) {
63
+ return path.join(dataDir, UPDATE_MIGRATION_STATE_FILE);
64
+ }
65
+ export function readUpdateMigrationState(statePath) {
66
+ try {
67
+ const raw = fs.readFileSync(statePath, "utf8");
68
+ const parsed = JSON.parse(raw);
69
+ if (!isPlainObject(parsed))
70
+ return {};
71
+ return {
72
+ mcp_cleanup_version: typeof parsed.mcp_cleanup_version === "string"
73
+ ? parsed.mcp_cleanup_version
74
+ : undefined,
75
+ updated_at: typeof parsed.updated_at === "string" ? parsed.updated_at : undefined
76
+ };
77
+ }
78
+ catch (error) {
79
+ if (error.code === "ENOENT")
80
+ return {};
81
+ return {};
82
+ }
83
+ }
84
+ export function writeUpdateMigrationState(statePath, state) {
85
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
86
+ const tmpPath = `${statePath}.${process.pid}.tmp`;
87
+ fs.writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
88
+ fs.renameSync(tmpPath, statePath);
89
+ }
90
+ export function readPackageVersion(startUrl = import.meta.url) {
91
+ const root = findPackageRoot(fileURLToPath(startUrl));
92
+ if (!root)
93
+ return "unknown";
94
+ try {
95
+ const raw = fs.readFileSync(path.join(root, "package.json"), "utf8");
96
+ const parsed = JSON.parse(raw);
97
+ return typeof parsed.version === "string" && parsed.version.trim()
98
+ ? parsed.version
99
+ : "unknown";
100
+ }
101
+ catch {
102
+ return "unknown";
103
+ }
104
+ }
105
+ function resolveMigrationDataDir(installOptions) {
106
+ const options = {
107
+ env: installOptions?.env,
108
+ platform: installOptions?.platform,
109
+ homeDir: installOptions?.homeDir
110
+ };
111
+ return resolveDataDir(options);
112
+ }
113
+ function findPackageRoot(startPath) {
114
+ let current;
115
+ try {
116
+ current = fs.statSync(startPath).isDirectory()
117
+ ? startPath
118
+ : path.dirname(startPath);
119
+ }
120
+ catch {
121
+ current = path.dirname(startPath);
122
+ }
123
+ while (true) {
124
+ const candidate = path.join(current, "package.json");
125
+ if (fs.existsSync(candidate))
126
+ return current;
127
+ const parent = path.dirname(current);
128
+ if (parent === current)
129
+ return null;
130
+ current = parent;
131
+ }
132
+ }
133
+ function isPlainObject(value) {
134
+ return typeof value === "object" && value !== null && !Array.isArray(value);
135
+ }