synomem 0.2.0 → 0.4.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/CHANGELOG.md +49 -0
- package/README.md +4 -4
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +8 -2
- package/dist/backend.js.map +1 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +379 -16
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +68 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +283 -12
- package/dist/client.js.map +1 -1
- package/dist/cloud.d.ts +16 -0
- package/dist/cloud.d.ts.map +1 -0
- package/dist/cloud.js +19 -0
- package/dist/cloud.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -1
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +55 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +177 -0
- package/dist/configure.js.map +1 -0
- package/dist/credentials.d.ts +15 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js.map +1 -1
- package/dist/import.d.ts +202 -38
- package/dist/import.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +72 -6
- package/dist/mcp/index.js.map +1 -1
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +8 -1
- package/dist/oauth.js.map +1 -1
- package/dist/ports/repository.d.ts +4 -1
- package/dist/ports/repository.d.ts.map +1 -1
- package/dist/projections.d.ts +9 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +75 -4
- package/dist/projections.js.map +1 -1
- package/dist/prompt.d.ts +28 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +72 -0
- package/dist/prompt.js.map +1 -0
- package/dist/remote.d.ts +30 -1
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +14 -0
- package/dist/remote.js.map +1 -1
- package/dist/schemas.d.ts +270 -55
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +120 -6
- package/dist/schemas.js.map +1 -1
- package/dist/service.d.ts +30 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/storage.d.ts +25 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +168 -15
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +123 -4
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +1 -1
- package/docs/examples.md +1 -1
- package/docs/mcp.md +1 -1
- package/docs/skill.md +1 -1
- package/docs/storage-format.md +1 -1
- package/package.json +8 -8
- package/src/backend.ts +8 -2
- package/src/cli.ts +543 -19
- package/src/client.ts +331 -11
- package/src/cloud.ts +19 -0
- package/src/config.ts +8 -1
- package/src/configure.ts +233 -0
- package/src/credentials.ts +17 -2
- package/src/index.ts +7 -1
- package/src/mcp/index.ts +95 -5
- package/src/oauth.ts +8 -1
- package/src/ports/repository.ts +5 -0
- package/src/projections.ts +74 -4
- package/src/prompt.ts +88 -0
- package/src/remote.ts +72 -0
- package/src/schemas.ts +125 -6
- package/src/service.ts +30 -0
- package/src/storage.ts +217 -14
- package/src/types.ts +129 -3
package/src/client.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
escapeMarkdown,
|
|
9
9
|
memoRecordsFromEvents,
|
|
10
10
|
noteRecordsFromEvents,
|
|
11
|
+
postRecordsFromEvents,
|
|
11
12
|
ProjectionManager,
|
|
12
13
|
recordsFromEvents,
|
|
13
14
|
taskRecordsFromEvents,
|
|
@@ -18,6 +19,8 @@ import {
|
|
|
18
19
|
agentLookupSchema,
|
|
19
20
|
bindRuntimeSchema,
|
|
20
21
|
createAgentSchema,
|
|
22
|
+
createPostSchema,
|
|
23
|
+
updatePostSchema,
|
|
21
24
|
createNoteSchema,
|
|
22
25
|
createTaskSchema,
|
|
23
26
|
createTodoSchema,
|
|
@@ -49,6 +52,10 @@ import type {
|
|
|
49
52
|
AgentRuntimeBinding,
|
|
50
53
|
BindRuntimeInput,
|
|
51
54
|
CreateAgentInput,
|
|
55
|
+
CreatePostInput,
|
|
56
|
+
PostRecord,
|
|
57
|
+
PostRoster,
|
|
58
|
+
UpdatePostInput,
|
|
52
59
|
Diagnostic,
|
|
53
60
|
DoctorResult,
|
|
54
61
|
GiveKudosInput,
|
|
@@ -99,7 +106,11 @@ export interface SynomemCoreOptions {
|
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
export class SynomemCore implements SynomemDomainService {
|
|
102
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Mutable because an agent actor is resolved to its canonical identity on
|
|
111
|
+
* init: callers name a handle, events record the opaque ID.
|
|
112
|
+
*/
|
|
113
|
+
actor: ActorIdentity;
|
|
103
114
|
private readonly repository: SynomemRepository;
|
|
104
115
|
private readonly projectionWriter: ProjectionWriter;
|
|
105
116
|
private readonly clock: () => Date;
|
|
@@ -114,6 +125,11 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
114
125
|
get: (idOrAlias: string) => this.getAgent(idOrAlias),
|
|
115
126
|
list: () => this.listAgents(),
|
|
116
127
|
resolve: (query: string) => this.resolveAgent(query),
|
|
128
|
+
archive: (idOrAlias: string) => this.setAgentStatus(idOrAlias, 'archived'),
|
|
129
|
+
restore: (idOrAlias: string) => this.setAgentStatus(idOrAlias, 'active'),
|
|
130
|
+
addAliases: (idOrAlias: string, aliases: string[]) => this.addAgentAliases(idOrAlias, aliases),
|
|
131
|
+
removeAliases: (idOrAlias: string, aliases: string[]) =>
|
|
132
|
+
this.removeAgentAliases(idOrAlias, aliases),
|
|
117
133
|
directory: () => this.agentDirectory(),
|
|
118
134
|
bindings: (idOrAlias: string) => this.listRuntimeBindings(idOrAlias),
|
|
119
135
|
bindRuntime: (input: BindRuntimeInput) => this.bindRuntime(input),
|
|
@@ -139,6 +155,27 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
139
155
|
archive: (input: { memoId: string; idempotencyKey?: string }) => this.archiveMemo(input),
|
|
140
156
|
};
|
|
141
157
|
|
|
158
|
+
readonly posts = {
|
|
159
|
+
create: (input: CreatePostInput) => this.createPost(input),
|
|
160
|
+
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
161
|
+
this.listItems({ ...input, kinds: ['post'] }),
|
|
162
|
+
get: (id: string) => this.getPost(id),
|
|
163
|
+
update: (input: UpdatePostInput) => this.updatePost(input),
|
|
164
|
+
archive: (input: { postId: string; reason?: string; idempotencyKey?: string }) =>
|
|
165
|
+
this.archivePost(input),
|
|
166
|
+
/*
|
|
167
|
+
* Acknowledging is an explicit call and always speaks for the caller alone.
|
|
168
|
+
* There is no bulk form and no acknowledge-on-behalf-of: an acknowledgement
|
|
169
|
+
* is one actor saying "I have seen this", and reading a post must never
|
|
170
|
+
* append one, or the roster stops meaning anything.
|
|
171
|
+
*/
|
|
172
|
+
acknowledge: (input: { postId: string; note?: string; idempotencyKey?: string }) =>
|
|
173
|
+
this.acknowledgePost(input),
|
|
174
|
+
withdrawAcknowledgment: (input: { postId: string; reason?: string }) =>
|
|
175
|
+
this.withdrawPostAcknowledgment(input),
|
|
176
|
+
roster: (postId: string) => this.postRoster(postId),
|
|
177
|
+
};
|
|
178
|
+
|
|
142
179
|
readonly notes = {
|
|
143
180
|
create: (input: CreateNoteInput) => this.createNote(input),
|
|
144
181
|
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
@@ -243,6 +280,30 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
243
280
|
if (this.initialized) return;
|
|
244
281
|
await this.repository.init();
|
|
245
282
|
this.initialized = true;
|
|
283
|
+
|
|
284
|
+
/*
|
|
285
|
+
* An agent actor is resolved to its canonical identity here.
|
|
286
|
+
*
|
|
287
|
+
* Callers name a handle because that is what people and harnesses know,
|
|
288
|
+
* but every event must record the opaque ID — otherwise renaming a handle
|
|
289
|
+
* would orphan the history written under the old one. The display name
|
|
290
|
+
* comes from the profile for the same reason a harness cannot assert it on
|
|
291
|
+
* the command line: the stored record is the authority, not the argument.
|
|
292
|
+
*
|
|
293
|
+
* An unresolvable name is left as given rather than rejected, so a system
|
|
294
|
+
* actor can still create the agent that does not exist yet. Writing as an
|
|
295
|
+
* unknown agent is refused later by the checks that already exist.
|
|
296
|
+
*/
|
|
297
|
+
if (this.actor.kind === 'agent') {
|
|
298
|
+
const resolved = await this.repository.resolveAgent(this.actor.id);
|
|
299
|
+
if (resolved.match) {
|
|
300
|
+
this.actor = {
|
|
301
|
+
kind: 'agent',
|
|
302
|
+
id: resolved.match.id,
|
|
303
|
+
...(resolved.match.displayName ? { displayName: resolved.match.displayName } : {}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
246
307
|
}
|
|
247
308
|
|
|
248
309
|
async close(): Promise<void> {
|
|
@@ -286,20 +347,29 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
286
347
|
this.checkAbort();
|
|
287
348
|
await this.repository.assertEventCompatibility();
|
|
288
349
|
const parsed = this.validate(() => createAgentSchema.parse(input));
|
|
289
|
-
if (await this.repository.getAgent(parsed.
|
|
290
|
-
throw new SynomemError('AGENT_EXISTS', `Agent or alias already exists: ${parsed.
|
|
350
|
+
if (await this.repository.getAgent(parsed.handle)) {
|
|
351
|
+
throw new SynomemError('AGENT_EXISTS', `Agent or alias already exists: ${parsed.handle}`);
|
|
291
352
|
}
|
|
292
353
|
const aliases = [...new Set(parsed.aliases ?? [])].sort();
|
|
293
|
-
if (aliases.includes(parsed.
|
|
294
|
-
throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own
|
|
354
|
+
if (aliases.includes(parsed.handle)) {
|
|
355
|
+
throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own handle as an alias.');
|
|
295
356
|
}
|
|
296
357
|
for (const alias of aliases) {
|
|
297
358
|
if (await this.repository.getAgent(alias)) {
|
|
298
359
|
throw new SynomemError('ALIAS_CONFLICT', `Alias already belongs to an agent: ${alias}`);
|
|
299
360
|
}
|
|
300
361
|
}
|
|
362
|
+
/*
|
|
363
|
+
* The canonical ID is generated here and never supplied by the caller.
|
|
364
|
+
* Every event references it permanently, so it has to be free of meaning:
|
|
365
|
+
* a caller that could choose it could choose one that collides with an
|
|
366
|
+
* archived agent's history, and a meaningful ID becomes a handle nobody can
|
|
367
|
+
* rename.
|
|
368
|
+
*/
|
|
301
369
|
const profile: AgentProfile = {
|
|
302
|
-
id:
|
|
370
|
+
id: this.nextId(),
|
|
371
|
+
handle: parsed.handle,
|
|
372
|
+
status: 'active',
|
|
303
373
|
displayName: parsed.displayName,
|
|
304
374
|
...(aliases.length ? { aliases } : {}),
|
|
305
375
|
...(parsed.description !== undefined ? { description: parsed.description } : {}),
|
|
@@ -327,8 +397,20 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
327
397
|
const existing = await this.repository.getAgent(idOrAlias);
|
|
328
398
|
if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
329
399
|
const aliases = parsed.aliases ? [...new Set(parsed.aliases)].sort() : existing.aliases;
|
|
330
|
-
|
|
331
|
-
|
|
400
|
+
const handle = parsed.handle ?? existing.handle;
|
|
401
|
+
if (aliases?.includes(handle)) {
|
|
402
|
+
throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own handle as an alias.');
|
|
403
|
+
}
|
|
404
|
+
// Renaming the handle is allowed and is why the canonical ID exists, but a
|
|
405
|
+
// handle another agent already answers to is still refused.
|
|
406
|
+
if (parsed.handle && parsed.handle !== existing.handle) {
|
|
407
|
+
const owner = await this.repository.getAgent(parsed.handle);
|
|
408
|
+
if (owner && owner.id !== existing.id) {
|
|
409
|
+
throw new SynomemError(
|
|
410
|
+
'ALIAS_CONFLICT',
|
|
411
|
+
`Handle already belongs to ${owner.id}: ${parsed.handle}`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
332
414
|
}
|
|
333
415
|
for (const alias of aliases ?? []) {
|
|
334
416
|
const owner = await this.repository.getAgent(alias);
|
|
@@ -356,6 +438,52 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
356
438
|
return updated;
|
|
357
439
|
}
|
|
358
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Archiving stops an agent acting without erasing it.
|
|
443
|
+
*
|
|
444
|
+
* Events reference the actor permanently, so deleting an agent would leave
|
|
445
|
+
* history pointing at nothing. Archived agents keep their records and their
|
|
446
|
+
* handle, and can be restored.
|
|
447
|
+
*/
|
|
448
|
+
private async setAgentStatus(
|
|
449
|
+
idOrAlias: string,
|
|
450
|
+
status: 'active' | 'archived',
|
|
451
|
+
): Promise<AgentProfile> {
|
|
452
|
+
this.checkAbort();
|
|
453
|
+
await this.repository.assertEventCompatibility();
|
|
454
|
+
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
455
|
+
const existing = await this.repository.getAgent(idOrAlias);
|
|
456
|
+
if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
457
|
+
if (existing.status === status) return existing;
|
|
458
|
+
const updated: AgentProfile = { ...existing, status };
|
|
459
|
+
await this.repository.transaction(async () => {
|
|
460
|
+
const event: SynomemEvent = {
|
|
461
|
+
...this.eventBase(existing.id, await this.repository.nextAggregateVersion(existing.id)),
|
|
462
|
+
type: 'agent.updated',
|
|
463
|
+
agentId: existing.id,
|
|
464
|
+
changes: { status },
|
|
465
|
+
};
|
|
466
|
+
await this.repository.updateAgent(updated, event.createdAt);
|
|
467
|
+
await this.repository.insertEvent(event);
|
|
468
|
+
});
|
|
469
|
+
await this.projectionWriter.syncAgent(updated.id);
|
|
470
|
+
return updated;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Adds aliases without disturbing the ones already there. */
|
|
474
|
+
private async addAgentAliases(idOrAlias: string, add: string[]): Promise<AgentProfile> {
|
|
475
|
+
const existing = await this.getAgent(idOrAlias);
|
|
476
|
+
const merged = [...new Set([...(existing.aliases ?? []), ...add])].sort();
|
|
477
|
+
return await this.updateAgent(existing.id, { aliases: merged });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async removeAgentAliases(idOrAlias: string, remove: string[]): Promise<AgentProfile> {
|
|
481
|
+
const existing = await this.getAgent(idOrAlias);
|
|
482
|
+
const drop = new Set(remove.map((alias) => alias.trim().toLowerCase()));
|
|
483
|
+
const kept = (existing.aliases ?? []).filter((alias) => !drop.has(alias));
|
|
484
|
+
return await this.updateAgent(existing.id, { aliases: kept });
|
|
485
|
+
}
|
|
486
|
+
|
|
359
487
|
private async getAgent(idOrAlias: string): Promise<AgentProfile> {
|
|
360
488
|
this.checkAbort();
|
|
361
489
|
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
@@ -757,6 +885,174 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
757
885
|
return await this.getMemoRecord(input.memoId);
|
|
758
886
|
}
|
|
759
887
|
|
|
888
|
+
private async getPostRecord(id: string): Promise<PostRecord> {
|
|
889
|
+
await this.requireVisibleItem(id, 'post');
|
|
890
|
+
const record = postRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
891
|
+
if (!record) throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${id}`);
|
|
892
|
+
return record;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private async getPost(id: string): Promise<PostRecord> {
|
|
896
|
+
this.checkAbort();
|
|
897
|
+
return await this.getPostRecord(id);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
private async createPost(input: CreatePostInput): Promise<{
|
|
901
|
+
record: PostRecord;
|
|
902
|
+
created: boolean;
|
|
903
|
+
deduplicated: boolean;
|
|
904
|
+
}> {
|
|
905
|
+
this.checkAbort();
|
|
906
|
+
await this.repository.assertEventCompatibility();
|
|
907
|
+
const parsed = this.validate(() => createPostSchema.parse(input));
|
|
908
|
+
|
|
909
|
+
// A reply inherits its parent's workspace by construction, and cannot name
|
|
910
|
+
// a different target — there is no target to name.
|
|
911
|
+
if (parsed.replyTo) await this.requireVisibleItem(parsed.replyTo, 'post');
|
|
912
|
+
|
|
913
|
+
const outcome = await this.repository.transaction(async () => {
|
|
914
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'post.created');
|
|
915
|
+
if (prior?.type === 'post.created') return { id: prior.id, created: false };
|
|
916
|
+
const id = this.nextId();
|
|
917
|
+
const event: SynomemEvent = {
|
|
918
|
+
...this.eventBase(id, 1, id),
|
|
919
|
+
type: 'post.created',
|
|
920
|
+
title: parsed.title,
|
|
921
|
+
body: parsed.body,
|
|
922
|
+
tags: [...new Set(parsed.tags ?? [])].sort(),
|
|
923
|
+
...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
|
|
924
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
925
|
+
...(parsed.source ? { source: parsed.source } : {}),
|
|
926
|
+
...(parsed.metadata ? { metadata: parsed.metadata } : {}),
|
|
927
|
+
};
|
|
928
|
+
await this.repository.insertEvent(event);
|
|
929
|
+
return { id, created: true };
|
|
930
|
+
});
|
|
931
|
+
return {
|
|
932
|
+
record: await this.getPostRecord(outcome.id),
|
|
933
|
+
created: outcome.created,
|
|
934
|
+
deduplicated: !outcome.created,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Only the author edits a post. Everyone else responds to it. */
|
|
939
|
+
private assertPostAuthor(record: PostRecord): void {
|
|
940
|
+
if (this.administrative) return;
|
|
941
|
+
if (record.event.actor.id !== this.actor.id || record.event.actor.kind !== this.actor.kind) {
|
|
942
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the author can change a post.');
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
private async updatePost(input: UpdatePostInput): Promise<PostRecord> {
|
|
947
|
+
this.checkAbort();
|
|
948
|
+
const parsed = this.validate(() => updatePostSchema.parse(input));
|
|
949
|
+
const record = await this.getPostRecord(parsed.postId);
|
|
950
|
+
this.assertPostAuthor(record);
|
|
951
|
+
if (record.status === 'archived') {
|
|
952
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'An archived post cannot be edited.');
|
|
953
|
+
}
|
|
954
|
+
if (record.version !== parsed.expectedVersion) {
|
|
955
|
+
throw new SynomemError(
|
|
956
|
+
'REVISION_CONFLICT',
|
|
957
|
+
`Post ${parsed.postId} is at version ${record.version}.`,
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
await this.repository.transaction(async () => {
|
|
961
|
+
const event: SynomemEvent = {
|
|
962
|
+
...this.eventBase(parsed.postId, await this.repository.nextAggregateVersion(parsed.postId)),
|
|
963
|
+
type: 'post.edited',
|
|
964
|
+
postId: parsed.postId,
|
|
965
|
+
title: parsed.title ?? record.title,
|
|
966
|
+
body: parsed.body ?? record.body,
|
|
967
|
+
tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
|
|
968
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
969
|
+
};
|
|
970
|
+
await this.repository.insertEvent(event);
|
|
971
|
+
});
|
|
972
|
+
return await this.getPostRecord(parsed.postId);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
private async archivePost(input: {
|
|
976
|
+
postId: string;
|
|
977
|
+
reason?: string;
|
|
978
|
+
idempotencyKey?: string;
|
|
979
|
+
}): Promise<PostRecord> {
|
|
980
|
+
this.checkAbort();
|
|
981
|
+
const record = await this.getPostRecord(input.postId);
|
|
982
|
+
this.assertPostAuthor(record);
|
|
983
|
+
if (record.status !== 'archived') {
|
|
984
|
+
await this.repository.transaction(async () => {
|
|
985
|
+
const event: SynomemEvent = {
|
|
986
|
+
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
987
|
+
type: 'post.archived',
|
|
988
|
+
postId: input.postId,
|
|
989
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
990
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
991
|
+
};
|
|
992
|
+
await this.repository.insertEvent(event);
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
return await this.getPostRecord(input.postId);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
private async acknowledgePost(input: {
|
|
999
|
+
postId: string;
|
|
1000
|
+
note?: string;
|
|
1001
|
+
idempotencyKey?: string;
|
|
1002
|
+
}): Promise<PostRecord> {
|
|
1003
|
+
this.checkAbort();
|
|
1004
|
+
const record = await this.getPostRecord(input.postId);
|
|
1005
|
+
// Acknowledging twice is the same statement, so the second is a no-op
|
|
1006
|
+
// rather than a second row or an error.
|
|
1007
|
+
const already = record.acknowledgments.some(
|
|
1008
|
+
(entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind,
|
|
1009
|
+
);
|
|
1010
|
+
if (!already) {
|
|
1011
|
+
await this.repository.transaction(async () => {
|
|
1012
|
+
const event: SynomemEvent = {
|
|
1013
|
+
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
1014
|
+
type: 'post.acknowledged',
|
|
1015
|
+
postId: input.postId,
|
|
1016
|
+
...(input.note ? { note: input.note } : {}),
|
|
1017
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
1018
|
+
};
|
|
1019
|
+
await this.repository.insertEvent(event);
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
return await this.getPostRecord(input.postId);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
private async withdrawPostAcknowledgment(input: {
|
|
1026
|
+
postId: string;
|
|
1027
|
+
reason?: string;
|
|
1028
|
+
}): Promise<PostRecord> {
|
|
1029
|
+
this.checkAbort();
|
|
1030
|
+
const record = await this.getPostRecord(input.postId);
|
|
1031
|
+
const mine = record.acknowledgments.some(
|
|
1032
|
+
(entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind,
|
|
1033
|
+
);
|
|
1034
|
+
if (mine) {
|
|
1035
|
+
await this.repository.transaction(async () => {
|
|
1036
|
+
const event: SynomemEvent = {
|
|
1037
|
+
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
1038
|
+
type: 'post.acknowledgment.withdrawn',
|
|
1039
|
+
postId: input.postId,
|
|
1040
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
1041
|
+
};
|
|
1042
|
+
await this.repository.insertEvent(event);
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
return await this.getPostRecord(input.postId);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
private async postRoster(postId: string): Promise<PostRoster> {
|
|
1049
|
+
this.checkAbort();
|
|
1050
|
+
await this.requireVisibleItem(postId, 'post');
|
|
1051
|
+
const roster = await this.repository.postRoster(postId);
|
|
1052
|
+
if (!roster) throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${postId}`);
|
|
1053
|
+
return roster;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
760
1056
|
private async getNoteRecord(id: string): Promise<NoteRecord> {
|
|
761
1057
|
await this.requireVisibleItem(id, 'note');
|
|
762
1058
|
const record = noteRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
@@ -1245,10 +1541,34 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1245
1541
|
return await this.getTodoRecord(input.todoId);
|
|
1246
1542
|
}
|
|
1247
1543
|
|
|
1544
|
+
/**
|
|
1545
|
+
* Turns an agent name in a filter into the canonical ID the records hold.
|
|
1546
|
+
*
|
|
1547
|
+
* Callers filter by the name they know — a handle or an alias — while every
|
|
1548
|
+
* record stores the opaque ID. Without this the filter silently matches
|
|
1549
|
+
* nothing, which reads as "there is nothing here" rather than "that name
|
|
1550
|
+
* means something else now".
|
|
1551
|
+
*
|
|
1552
|
+
* An unresolvable name is passed through unchanged so it can match a legacy
|
|
1553
|
+
* name-shaped ID rather than being swallowed.
|
|
1554
|
+
*/
|
|
1555
|
+
private async canonicalAgentId(name: string | undefined): Promise<string | undefined> {
|
|
1556
|
+
if (!name) return name;
|
|
1557
|
+
const resolved = await this.repository.resolveAgent(name);
|
|
1558
|
+
return resolved.match?.id ?? name;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1248
1561
|
private async listItems(input: ItemListInput): Promise<Page<ItemSummary>> {
|
|
1249
1562
|
this.checkAbort();
|
|
1250
1563
|
const parsed = this.validate(() => itemListInputSchema.parse(input));
|
|
1251
|
-
|
|
1564
|
+
const resolved = {
|
|
1565
|
+
...parsed,
|
|
1566
|
+
...(parsed.participantAgentId
|
|
1567
|
+
? { participantAgentId: await this.canonicalAgentId(parsed.participantAgentId) }
|
|
1568
|
+
: {}),
|
|
1569
|
+
...(parsed.actorId ? { actorId: await this.canonicalAgentId(parsed.actorId) } : {}),
|
|
1570
|
+
};
|
|
1571
|
+
return await this.repository.listItemSummaries(resolved, this.actor);
|
|
1252
1572
|
}
|
|
1253
1573
|
private async listItemChanges(input: ChangesInput): Promise<ChangePage> {
|
|
1254
1574
|
this.checkAbort();
|
|
@@ -1343,8 +1663,8 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1343
1663
|
* check that silently lags the migration runner reports a healthy database as
|
|
1344
1664
|
* broken.
|
|
1345
1665
|
*/
|
|
1346
|
-
const CURRENT_SCHEMA_VERSION =
|
|
1347
|
-
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5];
|
|
1666
|
+
const CURRENT_SCHEMA_VERSION = 7;
|
|
1667
|
+
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7];
|
|
1348
1668
|
|
|
1349
1669
|
export class SynomemClient extends SynomemCore implements SynomemService {
|
|
1350
1670
|
readonly home: string;
|
package/src/cloud.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synomem Cloud, as a constant rather than a question.
|
|
3
|
+
*
|
|
4
|
+
* Public onboarding must never ask for a service URL. A person setting up
|
|
5
|
+
* Synomem has no way to know whether an address they were given is the real
|
|
6
|
+
* one, and a prompt that accepts any origin is a prompt that can be phished.
|
|
7
|
+
* The hosted service therefore has one address, compiled in.
|
|
8
|
+
*
|
|
9
|
+
* `SYNOMEM_API_URL` remains for development and private deployments. It is
|
|
10
|
+
* deliberately undocumented in the README, the public docs, the packaged skill
|
|
11
|
+
* and ordinary help output — a private deployment is configured by whoever runs
|
|
12
|
+
* it, not discovered by an ordinary user.
|
|
13
|
+
*/
|
|
14
|
+
export const SYNOMEM_CLOUD_API_URL = 'https://api.synomem.ai';
|
|
15
|
+
|
|
16
|
+
export function cloudApiUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
17
|
+
const override = env.SYNOMEM_API_URL?.trim();
|
|
18
|
+
return override || SYNOMEM_CLOUD_API_URL;
|
|
19
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -73,7 +73,14 @@ export const configSchema = policySchema.extend({
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
export function resolveHome(explicitHome?: string): string {
|
|
76
|
-
|
|
76
|
+
/*
|
|
77
|
+
* `~/.synomem`, resolved as an exact directory.
|
|
78
|
+
*
|
|
79
|
+
* No detection of or migration from `~/.agents`: the project is greenfield,
|
|
80
|
+
* and code that quietly moves somebody's database is worse than a clear
|
|
81
|
+
* message telling them where the new home is.
|
|
82
|
+
*/
|
|
83
|
+
const candidate = explicitHome ?? process.env.SYNOMEM_HOME ?? resolve(homedir(), '.synomem');
|
|
77
84
|
if (candidate.includes('\0')) throw new SynomemError('UNSAFE_PATH', 'Storage home contains NUL.');
|
|
78
85
|
return resolve(candidate);
|
|
79
86
|
}
|