synomem 0.1.0 → 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/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +37 -1
- package/README.md +28 -15
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +311 -70
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +96 -17
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +333 -76
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.js +8 -8
- package/dist/import.d.ts +207 -13
- package/dist/import.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +156 -31
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp-server.js +54 -8
- package/dist/mcp-server.js.map +1 -1
- package/dist/ports/repository.d.ts +18 -1
- package/dist/ports/repository.d.ts.map +1 -1
- package/dist/projections.d.ts +18 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +137 -44
- package/dist/projections.js.map +1 -1
- package/dist/remote.d.ts +57 -9
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +43 -2
- package/dist/remote.js.map +1 -1
- package/dist/schemas.d.ts +302 -21
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +142 -26
- package/dist/schemas.js.map +1 -1
- package/dist/service.d.ts +57 -10
- package/dist/service.d.ts.map +1 -1
- package/dist/skill-install.d.ts +8 -2
- package/dist/skill-install.d.ts.map +1 -1
- package/dist/skill-install.js +6 -11
- package/dist/skill-install.js.map +1 -1
- package/dist/storage.d.ts +41 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +254 -36
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +213 -38
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +53 -20
- package/docs/examples.md +4 -4
- package/docs/mcp.md +12 -4
- package/docs/skill.md +10 -7
- package/docs/storage-format.md +4 -4
- package/openapi/synomem-v1.yaml +24 -24
- package/package.json +1 -1
- package/skills/synomem/SKILL.md +24 -8
- package/skills/synomem/agents/openai.yaml +1 -1
- package/skills/synomem/references/examples.md +1 -1
- package/src/cli.ts +572 -173
- package/src/client.ts +391 -79
- package/src/config.ts +8 -8
- package/src/index.ts +11 -1
- package/src/mcp/index.ts +189 -30
- package/src/mcp-server.ts +58 -8
- package/src/ports/repository.ts +16 -0
- package/src/projections.ts +139 -44
- package/src/remote.ts +118 -8
- package/src/schemas.ts +147 -26
- package/src/service.ts +59 -7
- package/src/skill-install.ts +14 -17
- package/src/storage.ts +326 -34
- package/src/types.ts +228 -39
package/src/storage.ts
CHANGED
|
@@ -22,13 +22,16 @@ import {
|
|
|
22
22
|
ensureDirectory,
|
|
23
23
|
readJsonFile,
|
|
24
24
|
} from './fs-utils.js';
|
|
25
|
+
import { dueInstant } from './projections.js';
|
|
25
26
|
import { actorSchema, eventSchema, profileSchema } from './schemas.js';
|
|
26
27
|
import type {
|
|
27
28
|
ActorIdentity,
|
|
28
29
|
SynomemConfig,
|
|
29
30
|
SynomemConfigOverrides,
|
|
30
31
|
AgentProfile,
|
|
32
|
+
AgentRuntimeBinding,
|
|
31
33
|
ChangePage,
|
|
34
|
+
JsonValue,
|
|
32
35
|
KudosChange,
|
|
33
36
|
SynomemEvent,
|
|
34
37
|
ItemChange,
|
|
@@ -186,6 +189,54 @@ CREATE INDEX kudos_current_actor ON kudos_current(actor_kind, actor_id, given_se
|
|
|
186
189
|
CREATE INDEX kudos_current_status ON kudos_current(status, revocation_status, given_sequence DESC);
|
|
187
190
|
`;
|
|
188
191
|
|
|
192
|
+
/**
|
|
193
|
+
* v4 records a task's or todo's deadline on the item index.
|
|
194
|
+
*
|
|
195
|
+
* The plan asks for "accepted Tasks past their due date" as a bounded query.
|
|
196
|
+
* Answering that from the event log means replaying every task on every call,
|
|
197
|
+
* so the deadline is projected alongside the rest of the summary. It is stored
|
|
198
|
+
* as an ISO instant: a date-only due date resolves to the end of that day, so
|
|
199
|
+
* "overdue" means the day has actually passed rather than merely started.
|
|
200
|
+
*/
|
|
201
|
+
const migrationV4 = `
|
|
202
|
+
ALTER TABLE items_current ADD COLUMN due_at TEXT;
|
|
203
|
+
CREATE INDEX items_current_due ON items_current(kind, status, due_at);
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* v5 makes alias lookup case-insensitive and unambiguous.
|
|
208
|
+
*
|
|
209
|
+
* The plan requires that `mycroft`, `Mycroft` and `Mike` may all resolve to one
|
|
210
|
+
* canonical agent, while a name two agents both claim must return candidates
|
|
211
|
+
* rather than guessing. A normalized column with a unique index enforces the
|
|
212
|
+
* second half at write time: the collision is refused when the alias is added,
|
|
213
|
+
* not discovered later by whoever happens to look it up first.
|
|
214
|
+
*
|
|
215
|
+
* Runtime bindings arrive here too. An agent is not one harness forever —
|
|
216
|
+
* Mycroft may run Hermes on two machines, or move between harnesses — so the
|
|
217
|
+
* binding is its own row keyed by agent, installation, runtime and profile
|
|
218
|
+
* rather than a field on the agent.
|
|
219
|
+
*/
|
|
220
|
+
const migrationV5 = `
|
|
221
|
+
ALTER TABLE aliases ADD COLUMN normalized_alias TEXT;
|
|
222
|
+
UPDATE aliases SET normalized_alias = lower(alias);
|
|
223
|
+
CREATE UNIQUE INDEX aliases_normalized ON aliases(normalized_alias);
|
|
224
|
+
|
|
225
|
+
CREATE TABLE agent_runtime_bindings (
|
|
226
|
+
id TEXT PRIMARY KEY,
|
|
227
|
+
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
228
|
+
installation_id TEXT,
|
|
229
|
+
runtime TEXT NOT NULL,
|
|
230
|
+
profile TEXT,
|
|
231
|
+
capabilities_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(capabilities_json)),
|
|
232
|
+
bound_at TEXT NOT NULL,
|
|
233
|
+
last_seen_at TEXT
|
|
234
|
+
) STRICT;
|
|
235
|
+
CREATE UNIQUE INDEX agent_runtime_bindings_unique
|
|
236
|
+
ON agent_runtime_bindings(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''));
|
|
237
|
+
CREATE INDEX agent_runtime_bindings_agent ON agent_runtime_bindings(agent_id);
|
|
238
|
+
`;
|
|
239
|
+
|
|
189
240
|
const migrationV3 = `
|
|
190
241
|
DROP TRIGGER IF EXISTS events_append_only_update;
|
|
191
242
|
DROP TRIGGER IF EXISTS events_append_only_delete;
|
|
@@ -303,10 +354,18 @@ function itemSummaryFromRow(row: ItemRow): ItemSummary {
|
|
|
303
354
|
};
|
|
304
355
|
}
|
|
305
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Resolves a due value to a comparable instant.
|
|
359
|
+
*
|
|
360
|
+
* A date-only deadline resolves to the END of that day, so "overdue" means the
|
|
361
|
+
* day has passed rather than merely begun. Treating 2026-09-15 as midnight
|
|
362
|
+
* would report a task due today as already late.
|
|
363
|
+
*/
|
|
306
364
|
function eventKind(event: SynomemEvent): RecordKind | undefined {
|
|
307
365
|
if (event.type.startsWith('kudos.')) return 'kudos';
|
|
308
366
|
if (event.type.startsWith('memo.')) return 'memo';
|
|
309
367
|
if (event.type.startsWith('note.')) return 'note';
|
|
368
|
+
if (event.type.startsWith('task.')) return 'task';
|
|
310
369
|
if (event.type.startsWith('todo.')) return 'todo';
|
|
311
370
|
return undefined;
|
|
312
371
|
}
|
|
@@ -440,7 +499,7 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
440
499
|
const version = Number(
|
|
441
500
|
(db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
|
|
442
501
|
);
|
|
443
|
-
if (version >
|
|
502
|
+
if (version > 5) {
|
|
444
503
|
throw new SynomemError(
|
|
445
504
|
'UNSUPPORTED_SCHEMA',
|
|
446
505
|
`Database schema version ${version} is newer than this package supports.`,
|
|
@@ -474,29 +533,56 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
474
533
|
if (afterV2 === 2) {
|
|
475
534
|
this.transactionSync(() => {
|
|
476
535
|
db.exec(migrationV3);
|
|
477
|
-
|
|
536
|
+
// The item index is left empty here and populated by v4, which adds the
|
|
537
|
+
// due-date column the projection writes. Rebuilding before that column
|
|
538
|
+
// exists fails on the first task.
|
|
478
539
|
db.prepare(
|
|
479
540
|
'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
|
|
480
541
|
).run(3, new Date().toISOString());
|
|
481
542
|
db.exec('PRAGMA user_version = 3');
|
|
482
543
|
});
|
|
483
544
|
}
|
|
545
|
+
const afterV3 = Number(
|
|
546
|
+
(db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
|
|
547
|
+
);
|
|
548
|
+
if (afterV3 === 3) {
|
|
549
|
+
this.transactionSync(() => {
|
|
550
|
+
db.exec(migrationV4);
|
|
551
|
+
this.rebuildItemsCurrentIndex();
|
|
552
|
+
db.prepare(
|
|
553
|
+
'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
|
|
554
|
+
).run(4, new Date().toISOString());
|
|
555
|
+
db.exec('PRAGMA user_version = 4');
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
const afterV4 = Number(
|
|
559
|
+
(db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
|
|
560
|
+
);
|
|
561
|
+
if (afterV4 === 4) {
|
|
562
|
+
this.transactionSync(() => {
|
|
563
|
+
db.exec(migrationV5);
|
|
564
|
+
db.prepare(
|
|
565
|
+
'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
|
|
566
|
+
).run(5, new Date().toISOString());
|
|
567
|
+
db.exec('PRAGMA user_version = 5');
|
|
568
|
+
});
|
|
569
|
+
}
|
|
484
570
|
}
|
|
485
571
|
|
|
486
572
|
private assertSchemaSupported(): void {
|
|
487
573
|
const version = Number(
|
|
488
574
|
(this.db().prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
|
|
489
575
|
);
|
|
490
|
-
if (version !==
|
|
491
|
-
if (version
|
|
576
|
+
if (version !== 5) {
|
|
577
|
+
if (version >= 1 && version <= 4) {
|
|
492
578
|
throw new SynomemError(
|
|
493
579
|
'UNSUPPORTED_SCHEMA',
|
|
494
|
-
|
|
580
|
+
`Database schema version ${version} requires migration. Open this home once with readOnly: false, then retry the read-only client.`,
|
|
495
581
|
);
|
|
496
582
|
}
|
|
497
583
|
throw new SynomemError(
|
|
498
584
|
'UNSUPPORTED_SCHEMA',
|
|
499
|
-
`Expected database schema version
|
|
585
|
+
`Expected database schema version 5; found ${version}.`,
|
|
500
586
|
);
|
|
501
587
|
}
|
|
502
588
|
}
|
|
@@ -673,6 +759,7 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
673
759
|
ownerDisplayName?: string;
|
|
674
760
|
assigneeAgentId?: string;
|
|
675
761
|
assigneeDisplayName?: string;
|
|
762
|
+
dueAt?: string;
|
|
676
763
|
}): void => {
|
|
677
764
|
this.db()
|
|
678
765
|
.prepare(
|
|
@@ -680,8 +767,8 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
680
767
|
item_id, kind, created_sequence, updated_sequence, created_at, updated_at,
|
|
681
768
|
actor_kind, actor_id, actor_display_name, title, tags_json, visibility, status,
|
|
682
769
|
recipient_agent_id, recipient_display_name, owner_agent_id, owner_display_name,
|
|
683
|
-
assignee_agent_id, assignee_display_name
|
|
684
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
770
|
+
assignee_agent_id, assignee_display_name, due_at
|
|
771
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
685
772
|
)
|
|
686
773
|
.run(
|
|
687
774
|
event.aggregateId,
|
|
@@ -703,6 +790,7 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
703
790
|
values.ownerDisplayName ?? null,
|
|
704
791
|
values.assigneeAgentId ?? null,
|
|
705
792
|
values.assigneeDisplayName ?? null,
|
|
793
|
+
values.dueAt ?? null,
|
|
706
794
|
);
|
|
707
795
|
};
|
|
708
796
|
const updateStatus = (status: string): void => {
|
|
@@ -762,35 +850,69 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
762
850
|
event.aggregateId,
|
|
763
851
|
);
|
|
764
852
|
} else if (event.type === 'note.archived') updateStatus('archived');
|
|
765
|
-
else if (event.type === '
|
|
853
|
+
else if (event.type === 'task.created') {
|
|
766
854
|
insert({
|
|
767
|
-
kind: '
|
|
855
|
+
kind: 'task',
|
|
768
856
|
title: event.title,
|
|
769
857
|
tags: event.tags,
|
|
770
858
|
visibility: event.visibility,
|
|
771
859
|
status: event.requiresAcceptance ? 'assigned' : 'open',
|
|
772
860
|
assigneeAgentId: event.assigneeAgentId,
|
|
773
861
|
assigneeDisplayName: event.assigneeDisplayName,
|
|
862
|
+
...((due) => (due ? { dueAt: due } : {}))(dueInstant(event.due)),
|
|
774
863
|
});
|
|
775
|
-
} else if (event.type === '
|
|
864
|
+
} else if (event.type === 'task.updated') {
|
|
776
865
|
this.db()
|
|
777
866
|
.prepare(
|
|
778
|
-
`UPDATE items_current SET title = ?, tags_json = ?, visibility = ?,
|
|
867
|
+
`UPDATE items_current SET title = ?, tags_json = ?, visibility = ?, due_at = ?,
|
|
779
868
|
updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
|
|
780
869
|
)
|
|
781
870
|
.run(
|
|
782
871
|
event.title,
|
|
783
872
|
JSON.stringify(event.tags ?? []),
|
|
784
873
|
event.visibility,
|
|
874
|
+
dueInstant(event.due) ?? null,
|
|
875
|
+
sequence,
|
|
876
|
+
event.createdAt,
|
|
877
|
+
event.aggregateId,
|
|
878
|
+
);
|
|
879
|
+
} else if (event.type === 'task.accepted') updateStatus('open');
|
|
880
|
+
else if (event.type === 'task.rejected') updateStatus('rejected');
|
|
881
|
+
else if (event.type === 'task.completed') updateStatus('completed');
|
|
882
|
+
else if (event.type === 'task.reopened') updateStatus('open');
|
|
883
|
+
else if (event.type === 'task.canceled') updateStatus('canceled');
|
|
884
|
+
else if (event.type === 'todo.created') {
|
|
885
|
+
// A Todo's owner IS its author, and it is always private. Recording the
|
|
886
|
+
// owner explicitly rather than inferring it from the actor keeps the
|
|
887
|
+
// owner-scoped read filters uniform across notes and todos.
|
|
888
|
+
insert({
|
|
889
|
+
kind: 'todo',
|
|
890
|
+
title: event.title,
|
|
891
|
+
tags: event.tags,
|
|
892
|
+
visibility: 'private',
|
|
893
|
+
status: 'open',
|
|
894
|
+
ownerAgentId: event.actor.id,
|
|
895
|
+
...(event.actor.displayName ? { ownerDisplayName: event.actor.displayName } : {}),
|
|
896
|
+
...((due) => (due ? { dueAt: due } : {}))(dueInstant(event.due)),
|
|
897
|
+
});
|
|
898
|
+
} else if (event.type === 'todo.updated') {
|
|
899
|
+
this.db()
|
|
900
|
+
.prepare(
|
|
901
|
+
`UPDATE items_current SET title = ?, tags_json = ?, due_at = ?,
|
|
902
|
+
updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
|
|
903
|
+
)
|
|
904
|
+
.run(
|
|
905
|
+
event.title,
|
|
906
|
+
JSON.stringify(event.tags ?? []),
|
|
907
|
+
dueInstant(event.due) ?? null,
|
|
785
908
|
sequence,
|
|
786
909
|
event.createdAt,
|
|
787
910
|
event.aggregateId,
|
|
788
911
|
);
|
|
789
|
-
} else if (event.type === 'todo.
|
|
790
|
-
else if (event.type === 'todo.rejected') updateStatus('rejected');
|
|
791
|
-
else if (event.type === 'todo.completed') updateStatus('completed');
|
|
912
|
+
} else if (event.type === 'todo.completed') updateStatus('completed');
|
|
792
913
|
else if (event.type === 'todo.reopened') updateStatus('open');
|
|
793
914
|
else if (event.type === 'todo.canceled') updateStatus('canceled');
|
|
915
|
+
else if (event.type === 'todo.archived') updateStatus('archived');
|
|
794
916
|
}
|
|
795
917
|
|
|
796
918
|
rebuildItemsCurrentIndex(): void {
|
|
@@ -1037,11 +1159,48 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1037
1159
|
if (input.pending) {
|
|
1038
1160
|
add(`((kind = 'kudos' AND status = 'unacknowledged') OR
|
|
1039
1161
|
(kind = 'memo' AND status = 'unread') OR
|
|
1040
|
-
(kind = '
|
|
1162
|
+
(kind = 'task' AND status IN ('assigned', 'open')))`);
|
|
1041
1163
|
}
|
|
1042
1164
|
if (input.visibility) add('visibility = ?', input.visibility);
|
|
1043
1165
|
if (input.from) add('created_at >= ?', input.from);
|
|
1044
1166
|
if (input.to) add('created_at <= ?', input.to);
|
|
1167
|
+
|
|
1168
|
+
// Unanswered discovery: items still waiting for somebody to respond. Kept
|
|
1169
|
+
// distinct from `pending`, which also counts accepted-and-in-progress work.
|
|
1170
|
+
if (input.awaitingResponse) {
|
|
1171
|
+
add(`((kind = 'kudos' AND status = 'unacknowledged') OR
|
|
1172
|
+
(kind = 'memo' AND status = 'unread') OR
|
|
1173
|
+
(kind = 'task' AND status = 'assigned'))`);
|
|
1174
|
+
}
|
|
1175
|
+
if (input.awaitingSince) add('created_at <= ?', input.awaitingSince);
|
|
1176
|
+
|
|
1177
|
+
// Overdue discovery: a deadline that has passed, on work still open. A
|
|
1178
|
+
// completed or canceled item is not overdue, however late it was.
|
|
1179
|
+
if (input.overdueAsOf) {
|
|
1180
|
+
add(
|
|
1181
|
+
`(due_at IS NOT NULL AND due_at < ? AND status IN ('assigned', 'open'))`,
|
|
1182
|
+
input.overdueAsOf,
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/*
|
|
1187
|
+
* Private todos are owner-only for EVERY viewer, including a human.
|
|
1188
|
+
*
|
|
1189
|
+
* The visibility rules below exempt human actors, on the reasoning that a
|
|
1190
|
+
* person operating a local home is its operator. That does not extend to
|
|
1191
|
+
* todos: the plan is explicit that a todo is visible only to its owner
|
|
1192
|
+
* except through an explicitly authorized administrative capability, and
|
|
1193
|
+
* this release has no such capability. Without this clause a human actor
|
|
1194
|
+
* would see every agent's private reminders in an item list.
|
|
1195
|
+
*/
|
|
1196
|
+
add(
|
|
1197
|
+
`(kind != 'todo' OR (owner_agent_id = ? AND ? = 'agent') OR (actor_id = ? AND actor_kind = ?))`,
|
|
1198
|
+
viewer.id,
|
|
1199
|
+
viewer.kind,
|
|
1200
|
+
viewer.id,
|
|
1201
|
+
viewer.kind,
|
|
1202
|
+
);
|
|
1203
|
+
|
|
1045
1204
|
if (viewer.kind !== 'human') {
|
|
1046
1205
|
if (viewer.kind === 'agent') {
|
|
1047
1206
|
add(
|
|
@@ -1259,7 +1418,7 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1259
1418
|
this.db()
|
|
1260
1419
|
.prepare(
|
|
1261
1420
|
`SELECT COUNT(*) AS count FROM events WHERE type IN
|
|
1262
|
-
('kudos.given', 'memo.sent', 'note.created', '
|
|
1421
|
+
('kudos.given', 'memo.sent', 'note.created', 'task.created')`,
|
|
1263
1422
|
)
|
|
1264
1423
|
.get() as { count: number }
|
|
1265
1424
|
).count,
|
|
@@ -1412,13 +1571,19 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1412
1571
|
'note.created',
|
|
1413
1572
|
'note.revised',
|
|
1414
1573
|
'note.archived',
|
|
1574
|
+
'task.created',
|
|
1575
|
+
'task.updated',
|
|
1576
|
+
'task.completed',
|
|
1577
|
+
'task.reopened',
|
|
1578
|
+
'task.accepted',
|
|
1579
|
+
'task.rejected',
|
|
1580
|
+
'task.canceled',
|
|
1415
1581
|
'todo.created',
|
|
1416
1582
|
'todo.updated',
|
|
1417
1583
|
'todo.completed',
|
|
1418
1584
|
'todo.reopened',
|
|
1419
|
-
'todo.accepted',
|
|
1420
|
-
'todo.rejected',
|
|
1421
1585
|
'todo.canceled',
|
|
1586
|
+
'todo.archived',
|
|
1422
1587
|
]);
|
|
1423
1588
|
if (
|
|
1424
1589
|
(typeof candidate.schemaVersion === 'number' && candidate.schemaVersion > 1) ||
|
|
@@ -1436,7 +1601,7 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1436
1601
|
legacy.kudosId ??
|
|
1437
1602
|
legacy.memoId ??
|
|
1438
1603
|
legacy.noteId ??
|
|
1439
|
-
legacy.
|
|
1604
|
+
legacy.taskId ??
|
|
1440
1605
|
(typeof legacy.agentId === 'string' ? legacy.agentId : undefined) ??
|
|
1441
1606
|
(typeof legacy.agent === 'object' && legacy.agent !== null
|
|
1442
1607
|
? (legacy.agent as { id?: unknown }).id
|
|
@@ -1480,9 +1645,7 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1480
1645
|
parsed.createdAt,
|
|
1481
1646
|
parsed.createdAt,
|
|
1482
1647
|
);
|
|
1483
|
-
|
|
1484
|
-
this.db().prepare('INSERT INTO aliases(alias, agent_id) VALUES (?, ?)').run(alias, parsed.id);
|
|
1485
|
-
}
|
|
1648
|
+
this.insertAliases(parsed.id, parsed.aliases ?? []);
|
|
1486
1649
|
}
|
|
1487
1650
|
|
|
1488
1651
|
updateAgent(profile: AgentProfile, updatedAt: string): void {
|
|
@@ -1491,22 +1654,151 @@ export class SynomemStorage implements SynomemRepository {
|
|
|
1491
1654
|
.prepare('UPDATE agents SET display_name = ?, profile_json = ?, updated_at = ? WHERE id = ?')
|
|
1492
1655
|
.run(parsed.displayName, JSON.stringify(parsed), updatedAt, parsed.id);
|
|
1493
1656
|
this.db().prepare('DELETE FROM aliases WHERE agent_id = ?').run(parsed.id);
|
|
1494
|
-
|
|
1495
|
-
this.db().prepare('INSERT INTO aliases(alias, agent_id) VALUES (?, ?)').run(alias, parsed.id);
|
|
1496
|
-
}
|
|
1657
|
+
this.insertAliases(parsed.id, parsed.aliases ?? []);
|
|
1497
1658
|
}
|
|
1498
1659
|
|
|
1499
1660
|
getAgent(idOrAlias: string): AgentProfile | undefined {
|
|
1500
|
-
const
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1661
|
+
const resolved = this.resolveAgent(idOrAlias);
|
|
1662
|
+
return resolved.match;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* Resolves a name to a canonical agent, reporting ambiguity rather than
|
|
1667
|
+
* guessing.
|
|
1668
|
+
*
|
|
1669
|
+
* The plan is specific: `mycroft`, `Mycroft` and `Mike` may all resolve to one
|
|
1670
|
+
* agent, but a name two visible agents both claim must return candidates. An
|
|
1671
|
+
* alias that collides with a different agent's canonical ID is exactly that
|
|
1672
|
+
* case — silently preferring the ID would attribute work to the wrong agent,
|
|
1673
|
+
* and the person who typed the name would never know.
|
|
1674
|
+
*/
|
|
1675
|
+
resolveAgent(query: string): { match?: AgentProfile; candidates: AgentProfile[] } {
|
|
1676
|
+
const normalized = query.trim().toLowerCase();
|
|
1677
|
+
const rows = this.db()
|
|
1505
1678
|
.prepare(
|
|
1506
|
-
|
|
1679
|
+
`SELECT DISTINCT a.profile_json
|
|
1680
|
+
FROM agents a
|
|
1681
|
+
LEFT JOIN aliases x ON x.agent_id = a.id
|
|
1682
|
+
WHERE lower(a.id) = ? OR x.normalized_alias = ?
|
|
1683
|
+
ORDER BY a.id ASC`,
|
|
1507
1684
|
)
|
|
1508
|
-
.
|
|
1509
|
-
|
|
1685
|
+
.all(normalized, normalized) as unknown as ProfileRow[];
|
|
1686
|
+
const candidates = rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
|
|
1687
|
+
return candidates.length === 1 ? { match: candidates[0]!, candidates } : { candidates };
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/* ------------------------------------------------------- runtime bindings */
|
|
1691
|
+
|
|
1692
|
+
listRuntimeBindings(agentId: string): AgentRuntimeBinding[] {
|
|
1693
|
+
const rows = this.db()
|
|
1694
|
+
.prepare(
|
|
1695
|
+
`SELECT id, agent_id, installation_id, runtime, profile, capabilities_json,
|
|
1696
|
+
bound_at, last_seen_at
|
|
1697
|
+
FROM agent_runtime_bindings WHERE agent_id = ? ORDER BY bound_at ASC`,
|
|
1698
|
+
)
|
|
1699
|
+
.all(agentId) as unknown as Array<{
|
|
1700
|
+
id: string;
|
|
1701
|
+
agent_id: string;
|
|
1702
|
+
installation_id: string | null;
|
|
1703
|
+
runtime: string;
|
|
1704
|
+
profile: string | null;
|
|
1705
|
+
capabilities_json: string;
|
|
1706
|
+
bound_at: string;
|
|
1707
|
+
last_seen_at: string | null;
|
|
1708
|
+
}>;
|
|
1709
|
+
return rows.map((row) => ({
|
|
1710
|
+
id: row.id,
|
|
1711
|
+
agentId: row.agent_id,
|
|
1712
|
+
...(row.installation_id ? { installationId: row.installation_id } : {}),
|
|
1713
|
+
runtime: row.runtime,
|
|
1714
|
+
...(row.profile ? { profile: row.profile } : {}),
|
|
1715
|
+
capabilities: JSON.parse(row.capabilities_json) as Record<string, JsonValue>,
|
|
1716
|
+
boundAt: row.bound_at,
|
|
1717
|
+
...(row.last_seen_at ? { lastSeenAt: row.last_seen_at } : {}),
|
|
1718
|
+
}));
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
bindRuntime(binding: {
|
|
1722
|
+
id: string;
|
|
1723
|
+
agentId: string;
|
|
1724
|
+
installationId?: string;
|
|
1725
|
+
runtime: string;
|
|
1726
|
+
profile?: string;
|
|
1727
|
+
capabilities?: Record<string, JsonValue>;
|
|
1728
|
+
boundAt: string;
|
|
1729
|
+
}): void {
|
|
1730
|
+
this.db()
|
|
1731
|
+
.prepare(
|
|
1732
|
+
`INSERT INTO agent_runtime_bindings(
|
|
1733
|
+
id, agent_id, installation_id, runtime, profile, capabilities_json, bound_at
|
|
1734
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1735
|
+
ON CONFLICT(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''))
|
|
1736
|
+
DO UPDATE SET capabilities_json = excluded.capabilities_json`,
|
|
1737
|
+
)
|
|
1738
|
+
.run(
|
|
1739
|
+
binding.id,
|
|
1740
|
+
binding.agentId,
|
|
1741
|
+
binding.installationId ?? null,
|
|
1742
|
+
binding.runtime,
|
|
1743
|
+
binding.profile ?? null,
|
|
1744
|
+
JSON.stringify(binding.capabilities ?? {}),
|
|
1745
|
+
binding.boundAt,
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
unbindRuntime(bindingId: string): boolean {
|
|
1750
|
+
const result = this.db()
|
|
1751
|
+
.prepare('DELETE FROM agent_runtime_bindings WHERE id = ?')
|
|
1752
|
+
.run(bindingId);
|
|
1753
|
+
return Number(result.changes) > 0;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/**
|
|
1757
|
+
* Advisory only. Records that Synomem observed this binding act — never that
|
|
1758
|
+
* the runtime is reachable now, and never that a delivery succeeded.
|
|
1759
|
+
*/
|
|
1760
|
+
touchRuntimeBinding(agentId: string, runtime: string, at: string): void {
|
|
1761
|
+
this.db()
|
|
1762
|
+
.prepare(
|
|
1763
|
+
'UPDATE agent_runtime_bindings SET last_seen_at = ? WHERE agent_id = ? AND runtime = ?',
|
|
1764
|
+
)
|
|
1765
|
+
.run(at, agentId, runtime);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* Writes an agent's aliases, refusing any that would make a name ambiguous.
|
|
1770
|
+
*
|
|
1771
|
+
* Two collisions matter and both are rejected here rather than at lookup: an
|
|
1772
|
+
* alias another agent already claims, and an alias equal to a different
|
|
1773
|
+
* agent's canonical ID. Catching them at write means the person adding the
|
|
1774
|
+
* alias sees the conflict, instead of a later reader silently getting one of
|
|
1775
|
+
* two possible agents.
|
|
1776
|
+
*/
|
|
1777
|
+
private insertAliases(agentId: string, aliases: string[]): void {
|
|
1778
|
+
for (const alias of aliases) {
|
|
1779
|
+
const normalized = alias.trim().toLowerCase();
|
|
1780
|
+
const conflictingAgent = this.db()
|
|
1781
|
+
.prepare('SELECT id FROM agents WHERE lower(id) = ? AND id != ?')
|
|
1782
|
+
.get(normalized, agentId) as { id: string } | undefined;
|
|
1783
|
+
if (conflictingAgent) {
|
|
1784
|
+
throw new SynomemError(
|
|
1785
|
+
'ALIAS_CONFLICT',
|
|
1786
|
+
`Alias "${alias}" is already the canonical ID of agent ${conflictingAgent.id}. Aliases must resolve to exactly one agent.`,
|
|
1787
|
+
);
|
|
1788
|
+
}
|
|
1789
|
+
const conflictingAlias = this.db()
|
|
1790
|
+
.prepare('SELECT agent_id FROM aliases WHERE normalized_alias = ? AND agent_id != ?')
|
|
1791
|
+
.get(normalized, agentId) as { agent_id: string } | undefined;
|
|
1792
|
+
if (conflictingAlias) {
|
|
1793
|
+
throw new SynomemError(
|
|
1794
|
+
'ALIAS_CONFLICT',
|
|
1795
|
+
`Alias "${alias}" already belongs to agent ${conflictingAlias.agent_id}. Aliases must resolve to exactly one agent.`,
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
this.db()
|
|
1799
|
+
.prepare('INSERT INTO aliases(alias, agent_id, normalized_alias) VALUES (?, ?, ?)')
|
|
1800
|
+
.run(alias, agentId, normalized);
|
|
1801
|
+
}
|
|
1510
1802
|
}
|
|
1511
1803
|
|
|
1512
1804
|
listAgents(): AgentProfile[] {
|