@pulsarinteractive/geniustasker 0.1.0-beta.1 → 0.1.0-beta.10

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/CONTRIBUTING.md +72 -0
  3. package/LICENSE +21 -0
  4. package/LICENSES/Apache-2.0.txt +202 -0
  5. package/README.md +47 -370
  6. package/SECURITY.md +14 -0
  7. package/THIRD_PARTY_NOTICES.md +15 -0
  8. package/docs/ARCHITECTURE.md +144 -0
  9. package/docs/CHATGPT.md +86 -0
  10. package/docs/CLI.md +444 -0
  11. package/docs/DOCUMENTATION_SYNC.md +144 -0
  12. package/docs/INTEGRATIONS.md +62 -0
  13. package/docs/LIMITS.md +62 -0
  14. package/docs/MEMORY.md +38 -0
  15. package/docs/OPENAI.md +21 -0
  16. package/docs/OPENAI_PUBLICATION.md +22 -0
  17. package/docs/PLANNING.md +79 -0
  18. package/docs/PROJECT_FILTERS.md +77 -0
  19. package/docs/RELEASING.md +23 -0
  20. package/docs/TESTING.md +52 -0
  21. package/docs/WORKFLOWS.md +37 -0
  22. package/examples/codex.toml +3 -0
  23. package/examples/mcp.json +8 -0
  24. package/mcp.json +10 -0
  25. package/package.json +45 -9
  26. package/plugin.json +24 -0
  27. package/remote/tool-catalog.json +1374 -0
  28. package/remote/workflows.json +67 -0
  29. package/skills/geniustasker/SKILL.md +49 -116
  30. package/skills/geniustasker/agents/openai.yaml +4 -0
  31. package/skills/geniustasker/references/collaboration.md +70 -0
  32. package/skills/geniustasker/references/incremental-reads.md +16 -0
  33. package/skills/geniustasker/references/profiles.md +22 -0
  34. package/skills/geniustasker/references/project-reads.md +43 -0
  35. package/skills/geniustasker-documentation/SKILL.md +39 -0
  36. package/skills/geniustasker-documentation/agents/openai.yaml +4 -0
  37. package/skills/geniustasker-documentation/references/structure.md +29 -0
  38. package/skills/geniustasker-graph/SKILL.md +31 -0
  39. package/skills/geniustasker-graph/agents/openai.yaml +4 -0
  40. package/skills/geniustasker-media/SKILL.md +33 -0
  41. package/skills/geniustasker-media/agents/openai.yaml +4 -0
  42. package/skills/geniustasker-memory/SKILL.md +120 -0
  43. package/skills/geniustasker-memory/agents/openai.yaml +4 -0
  44. package/skills/geniustasker-memory/references/knowledge-workflows.md +57 -0
  45. package/skills/geniustasker-planning/SKILL.md +28 -0
  46. package/skills/geniustasker-planning/agents/openai.yaml +4 -0
  47. package/src/changes.mjs +104 -28
  48. package/src/cli.mjs +453 -77
  49. package/src/client.mjs +203 -34
  50. package/src/command-catalog.mjs +545 -263
  51. package/src/commands.mjs +215 -52
  52. package/src/documentation-graph.mjs +251 -0
  53. package/src/documentation-store.mjs +589 -0
  54. package/src/documentation-sync.mjs +517 -0
  55. package/src/documentation.mjs +136 -0
  56. package/src/errors.mjs +55 -12
  57. package/src/knowledge.mjs +109 -0
  58. package/src/login.mjs +190 -47
  59. package/src/mcp.mjs +310 -43
  60. package/src/media.mjs +307 -0
  61. package/src/presentation.d.mts +1 -0
  62. package/src/presentation.mjs +12 -0
  63. package/src/profiles.mjs +123 -32
  64. package/src/read-cache.mjs +111 -0
  65. package/src/store.mjs +101 -26
  66. package/src/transport.mjs +165 -32
  67. package/src/workflows.mjs +9 -0
package/src/commands.mjs CHANGED
@@ -7,102 +7,263 @@ import catalog from './command-catalog.mjs';
7
7
  import { CredentialStore } from './store.mjs';
8
8
  import { AgentError, fail, safeError } from './errors.mjs';
9
9
 
10
- export const operationIdSchema = z.string().regex(/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/);
11
- const identifier = z.string().min(1).max(128).regex(/^[^\u0000-\u001f]+$/u);
12
- export const commandSchema = z.object({
13
- operationId: operationIdSchema, issuedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
14
- scope: z.string().max(256).regex(/^(project|memory):[^\u0000-\u001f]+$/u),
15
- epoch: identifier, id: identifier, expectedRevision: z.string().regex(/^[1-9][0-9]{0,15}$/).nullable(),
16
- kind: z.enum(Object.keys(catalog.commands)), data: z.record(z.string(), z.json()),
17
- }).strict();
10
+ export const operationIdSchema = z
11
+ .string()
12
+ .regex(/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/);
13
+ const identifier = z
14
+ .string()
15
+ .min(1)
16
+ .max(128)
17
+ // eslint-disable-next-line no-control-regex -- Reject control characters in untrusted input.
18
+ .regex(/^[^\u0000-\u001f]+$/u);
19
+ export const commandSchema = z
20
+ .object({
21
+ operationId: operationIdSchema,
22
+ issuedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
23
+ scope: z
24
+ .string()
25
+ .max(256)
26
+ // eslint-disable-next-line no-control-regex -- Reject control characters in untrusted input.
27
+ .regex(/^(project|memory):[^\u0000-\u001f]+$/u),
28
+ epoch: identifier,
29
+ id: identifier,
30
+ expectedRevision: z
31
+ .string()
32
+ .regex(/^[1-9][0-9]{0,15}$/)
33
+ .nullable(),
34
+ kind: z.enum(Object.keys(catalog.commands)),
35
+ data: z.record(z.string(), z.json()),
36
+ })
37
+ .strict();
18
38
  export const draftSchema = commandSchema.omit({ operationId: true, issuedAt: true });
19
- const canonical = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
20
- ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
39
+ const canonical = (value) =>
40
+ JSON.stringify(value, (_, item) =>
41
+ item && typeof item === 'object' && !Array.isArray(item)
42
+ ? Object.fromEntries(
43
+ Object.keys(item)
44
+ .sort()
45
+ .map((key) => [key, item[key]]),
46
+ )
47
+ : item,
48
+ );
21
49
  const checked = (schema, value) => {
22
50
  const parsed = schema.safeParse(value);
23
- if (!parsed.success) fail('invalid_arguments', 'The command does not match its schema. Use commands describe; do not supply credentials or server-owned fields.');
51
+ if (!parsed.success) {
52
+ fail(
53
+ 'invalid_arguments',
54
+ 'The command does not match its schema. Use commands describe; do not supply credentials or server-owned fields.',
55
+ );
56
+ }
24
57
  return parsed.data;
25
58
  };
59
+ /** Validate the public envelope, generated field allowlist and resource scope. */
26
60
  export function validateCommand(value) {
27
- const command = checked(commandSchema, value), definition = catalog.commands[command.kind];
28
- if (Buffer.byteLength(JSON.stringify(command)) > catalog.maxCommandBytes ||
29
- Object.keys(command.data).some(key => !definition.fields.includes(key)) ||
30
- (definition.create ? command.expectedRevision !== null : command.expectedRevision === null))
31
- fail('invalid_arguments', 'Use the declared command fields and size limit. Creation requires a null revision; other commands require the current record revision.');
61
+ const command = checked(commandSchema, value),
62
+ definition = catalog.commands[command.kind];
63
+ if (
64
+ Buffer.byteLength(JSON.stringify(command)) > catalog.maxCommandBytes ||
65
+ Object.keys(command.data).some((key) => !definition.fields.includes(key)) ||
66
+ (definition.create ? command.expectedRevision !== null : command.expectedRevision === null)
67
+ ) {
68
+ fail(
69
+ 'invalid_arguments',
70
+ 'Use the declared command fields and size limit. Creation requires a null revision; other commands require the current record revision.',
71
+ );
72
+ }
32
73
  const prefix = definition.module.startsWith('Memory/') ? 'memory:' : 'project:';
33
- if (!command.scope.startsWith(prefix) || definition.module.endsWith('/$') && command.id !== command.scope.slice(prefix.length)) fail('invalid_arguments', 'The command must target the matching project or memory scope.');
34
- if (command.kind === 'ticket.plan' && command.id !== command.scope.slice(8))
35
- fail('invalid_arguments', 'ticket.plan targets the project ID and root revision; changes carry each task expectedVersion.');
74
+ if (
75
+ !command.scope.startsWith(prefix) ||
76
+ (definition.module.endsWith('/$') && command.id !== command.scope.slice(prefix.length))
77
+ ) {
78
+ fail('invalid_arguments', 'The command must target the matching project or memory scope.');
79
+ }
80
+ if (
81
+ ['memory.create', 'project.create'].includes(command.kind) &&
82
+ (!/^[a-f\d]{8}-[a-f\d]{4}-4[a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/i.test(command.id) ||
83
+ command.epoch !== command.id)
84
+ ) {
85
+ fail(
86
+ 'invalid_arguments',
87
+ 'Create a space with a UUID v4 as id and the same initial epoch. Use project:<id> or memory:<id> as appropriate; keep source identifiers in page sourceId.',
88
+ );
89
+ }
90
+ if (command.kind === 'ticket.plan' && command.id !== command.scope.slice(8)) {
91
+ fail(
92
+ 'invalid_arguments',
93
+ 'ticket.plan targets the project ID and root revision; changes carry each task expectedVersion.',
94
+ );
95
+ }
36
96
  return command;
37
97
  }
38
- export function describeCommands() { return { ...catalog, envelope: z.toJSONSchema(commandSchema), draft: z.toJSONSchema(draftSchema),
39
- recovery: 'Retain operationId, issuedAt and the complete body. Retry an uncertain write unchanged; reread before creating a new edit after a conflict.' }; }
98
+ export function describeCommands() {
99
+ return {
100
+ ...catalog,
101
+ envelope: z.toJSONSchema(commandSchema),
102
+ draft: z.toJSONSchema(draftSchema),
103
+ recovery:
104
+ 'Retain operationId, issuedAt and the complete body. Retry an uncertain write unchanged; reread before creating a new edit after a conflict.',
105
+ };
106
+ }
107
+ /** Read one bounded regular JSON file, rejecting symlinks and non-file inputs. */
40
108
  export async function readCommandFile(path) {
41
- if (typeof path !== 'string') fail('invalid_arguments', 'Supply --file with a JSON command or draft.');
109
+ if (typeof path !== 'string') {
110
+ fail('invalid_arguments', 'Supply --file with a JSON command or draft.');
111
+ }
42
112
  let handle;
43
113
  try {
44
114
  handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
45
115
  const stat = await handle.stat();
46
- if (!stat.isFile() || stat.size > catalog.maxCommandBytes) fail('invalid_arguments', 'Use a regular JSON file no larger than 32 KiB.');
47
- const bytes = Buffer.alloc(catalog.maxCommandBytes + 1), result = await handle.read(bytes, 0, bytes.length, 0);
48
- if (result.bytesRead > catalog.maxCommandBytes) fail('invalid_arguments', 'Command file exceeds 32 KiB.');
116
+ if (!stat.isFile() || stat.size > catalog.maxCommandBytes) {
117
+ fail(
118
+ 'invalid_arguments',
119
+ `Use a regular JSON file no larger than ${catalog.maxCommandBytes} bytes.`,
120
+ );
121
+ }
122
+ const bytes = Buffer.alloc(catalog.maxCommandBytes + 1),
123
+ result = await handle.read(bytes, 0, bytes.length, 0);
124
+ if (result.bytesRead > catalog.maxCommandBytes) {
125
+ fail('invalid_arguments', `Command file exceeds ${catalog.maxCommandBytes} bytes.`);
126
+ }
49
127
  return JSON.parse(bytes.subarray(0, result.bytesRead).toString('utf8'));
50
128
  } catch (error) {
51
- if (error instanceof AgentError) throw error;
129
+ if (error instanceof AgentError) {
130
+ throw error;
131
+ }
52
132
  fail('invalid_arguments', 'Could not read a regular, valid JSON command file.');
53
- } finally { await handle?.close(); }
133
+ } finally {
134
+ await handle?.close();
135
+ }
54
136
  }
55
- const binding = session => ({ origin: session.origin, connectionId: session.connection_id, profileId: session.profile_id, sessionId: session.session_id });
56
- const receiptSchema = z.object({ operationId: operationIdSchema, scope: z.string(), kind: z.string(), epoch: z.string(), cursor: z.string(),
57
- items: z.array(z.object({ id: z.string(), revision: z.string(), collection: z.string(), deleted: z.boolean() }).strict()),
58
- affectedCollections: z.array(z.string()),
59
- }).strict();
137
+ const binding = (session) => ({
138
+ origin: session.origin,
139
+ connectionId: session.connection_id,
140
+ profileId: session.profile_id,
141
+ sessionId: session.session_id,
142
+ });
143
+ const receiptSchema = z
144
+ .object({
145
+ operationId: operationIdSchema,
146
+ scope: z.string(),
147
+ kind: z.string(),
148
+ epoch: z.string(),
149
+ cursor: z.string(),
150
+ items: z.array(
151
+ z
152
+ .object({
153
+ id: z.string(),
154
+ revision: z.string(),
155
+ collection: z.string(),
156
+ deleted: z.boolean(),
157
+ })
158
+ .strict(),
159
+ ),
160
+ affectedCollections: z.array(z.string()),
161
+ })
162
+ .strict();
60
163
 
61
164
  /** A journal is bound to one authenticated terminal identity. Persist the full
62
165
  * request before the network call, never regenerate a UUID after a timeout.
63
166
  * It contains private project text but no access/refresh tokens. A process crash
64
167
  * leaves its operation lock for explicit recovery after all clients stop. */
65
168
  export class CommandJournal {
66
- constructor(client) { this.client = client; }
169
+ constructor(client) {
170
+ this.client = client;
171
+ }
67
172
  store(id) {
68
173
  checked(operationIdSchema, id);
69
- return new CredentialStore({ directory: join(this.client.store.directory, 'operations', this.client.store.context), context: id, maxBytes: 524288 });
174
+ return new CredentialStore({
175
+ directory: join(this.client.store.directory, 'operations', this.client.store.context),
176
+ context: id,
177
+ maxBytes: 524288,
178
+ });
70
179
  }
71
180
  async prepare(draft) {
72
- const input = validateCommand({ ...checked(draftSchema, draft), operationId: randomUUID(), issuedAt: Date.now() });
73
- const identity = binding(await this.client.session()), store = this.store(input.operationId);
74
- await store.locked(() => store.write({ formatVersion: 1, binding: identity, input, state: 'prepared' }), this.client.transport.signal);
181
+ const input = validateCommand({
182
+ ...checked(draftSchema, draft),
183
+ operationId: randomUUID(),
184
+ issuedAt: Date.now(),
185
+ });
186
+ const identity = binding(await this.client.session()),
187
+ store = this.store(input.operationId);
188
+ await store.locked(
189
+ () => store.write({ formatVersion: 1, binding: identity, input, state: 'prepared' }),
190
+ this.client.transport.signal,
191
+ );
75
192
  return { operationId: input.operationId, state: 'prepared', request: input };
76
193
  }
77
194
  async inspect(id) {
78
195
  const row = await this.store(id).read();
79
- if (!row || row.formatVersion !== 1 || row.input?.operationId !== id) fail('operation_not_found', 'This context has no valid local operation with that ID.');
80
- return { operationId: id, source: 'local_journal', state: row.state, request: row.input, ...(row.receipt ? { receipt: row.receipt } : {}), ...(row.error ? { error: row.error } : {}) };
196
+ if (!row || row.formatVersion !== 1 || row.input?.operationId !== id) {
197
+ fail('operation_not_found', 'This context has no valid local operation with that ID.');
198
+ }
199
+ return {
200
+ operationId: id,
201
+ source: 'local_journal',
202
+ state: row.state,
203
+ request: row.input,
204
+ ...(row.receipt ? { receipt: row.receipt } : {}),
205
+ ...(row.error ? { error: row.error } : {}),
206
+ };
81
207
  }
82
208
  async execute(value) {
83
- const input = validateCommand(value), store = this.store(input.operationId);
209
+ const input = validateCommand(value),
210
+ store = this.store(input.operationId);
84
211
  return store.locked(async () => {
85
212
  // Capture token and identity together under the refresh lock. A later
86
213
  // logout/login cannot turn this pending operation into another profile.
87
- const session = await this.client.authorizedSession(), identity = binding(session);
214
+ const session = await this.client.authorizedSession(),
215
+ identity = binding(session);
88
216
  let row = await store.read();
89
- if (row && (row.formatVersion !== 1 || canonical(row.binding) !== canonical(identity)))
90
- fail('operation_identity_mismatch', 'This operation belongs to another connection, profile, session or environment.', { remediation: 'Inspect it locally and reread current records. Do not replay it through a replacement login.' });
91
- if (row && canonical(row.input) !== canonical(input))
92
- fail('idempotency_mismatch', 'The operation ID already belongs to a different immutable request.', { status: 409, remediation: 'Use commands inspect, then retry the saved operation.' });
93
- if (row?.state === 'confirmed') return { source: 'local_receipt', receipt: row.receipt };
217
+ if (row && (row.formatVersion !== 1 || canonical(row.binding) !== canonical(identity))) {
218
+ fail(
219
+ 'operation_identity_mismatch',
220
+ 'This operation belongs to another connection, profile, session or environment.',
221
+ {
222
+ remediation:
223
+ 'Inspect it locally and reread current records. Do not replay it through a replacement login.',
224
+ },
225
+ );
226
+ }
227
+ if (row && canonical(row.input) !== canonical(input)) {
228
+ fail(
229
+ 'idempotency_mismatch',
230
+ 'The operation ID already belongs to a different immutable request.',
231
+ { status: 409, remediation: 'Use commands inspect, then retry the saved operation.' },
232
+ );
233
+ }
234
+ if (row?.state === 'confirmed') {
235
+ return { source: 'local_receipt', receipt: row.receipt };
236
+ }
94
237
  row = { formatVersion: 1, binding: identity, input, state: 'uncertain' };
95
238
  await store.write(row);
96
239
  try {
97
- const receipt = await this.client.transport.request('/api/agents/v1/commands', { method: 'POST', json: input, token: session.access_token });
98
- if (!receiptSchema.safeParse(receipt).success || ['operationId', 'scope', 'kind', 'epoch'].some(key => receipt[key] !== input[key]))
99
- fail('invalid_response', 'The command receipt was invalid. The server may have committed.', { execution: 'unknown', remediation: 'Retry the identical saved operation.' });
240
+ const receipt = await this.client.transport.request('/api/agents/v1/commands', {
241
+ method: 'POST',
242
+ json: input,
243
+ token: session.access_token,
244
+ });
245
+ if (
246
+ !receiptSchema.safeParse(receipt).success ||
247
+ ['operationId', 'scope', 'kind', 'epoch'].some((key) => receipt[key] !== input[key])
248
+ ) {
249
+ fail(
250
+ 'invalid_response',
251
+ 'The command receipt was invalid. The server may have committed.',
252
+ { execution: 'unknown', remediation: 'Retry the identical saved operation.' },
253
+ );
254
+ }
100
255
  await store.write({ ...row, state: 'confirmed', receipt });
101
256
  return { source: 'server', receipt };
102
257
  } catch (error) {
103
258
  const safe = safeError(error);
104
- const result = new AgentError(safe.code, safe.message, { status: safe.status, retryable: safe.retryable, remediation: safe.remediation,
105
- execution: 'unknown', ...safe.details, operationId: input.operationId });
259
+ const result = new AgentError(safe.code, safe.message, {
260
+ status: safe.status,
261
+ retryable: safe.retryable,
262
+ remediation: safe.remediation,
263
+ execution: 'unknown',
264
+ ...safe.details,
265
+ operationId: input.operationId,
266
+ });
106
267
  // The uncertainty marker is already durable. Preserve the useful original
107
268
  // error/operation ID even if disk pressure prevents saving diagnostics.
108
269
  await store.write({ ...row, error: result.toJSON() }).catch(() => {});
@@ -110,5 +271,7 @@ export class CommandJournal {
110
271
  }
111
272
  }, this.client.transport.signal);
112
273
  }
113
- async retry(id) { return this.execute((await this.inspect(id)).request); }
274
+ async retry(id) {
275
+ return this.execute((await this.inspect(id)).request);
276
+ }
114
277
  }
@@ -0,0 +1,251 @@
1
+ import * as z from 'zod/v4';
2
+ import { readChanges } from './changes.mjs';
3
+ import { fail } from './errors.mjs';
4
+ const id = z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/);
5
+ const recordSchema = z.object({
6
+ id,
7
+ revision: z.string().regex(/^[0-9]+$/),
8
+ data: z.record(z.string(), z.unknown()),
9
+ });
10
+ const parse = (schema, value) => {
11
+ const result = schema.safeParse(value);
12
+ if (!result.success) {
13
+ fail('invalid_response', 'The Graph source format is invalid.');
14
+ }
15
+ return result.data;
16
+ };
17
+ const contextOf = (tree) => ({ epoch: tree.epoch, checkpoint: tree.checkpoint });
18
+ const equivalentAccess = (before, tree) =>
19
+ before?.epoch === tree.epoch && before.accessRevision === tree.accessRevision;
20
+ const escape = (value) =>
21
+ String(value)
22
+ .replace(/[\r\n]/g, ' ')
23
+ .replace(/[\\`*_[\]<>|]/g, '\\$&');
24
+
25
+ async function graphCollection(client, scope, collection, tree, previous, cursor, downloads) {
26
+ const records = new Map((previous ?? []).map((record) => [record.id, record]));
27
+ if (cursor) {
28
+ try {
29
+ for (let count = 0; count < 1000; count++) {
30
+ const page = await readChanges(client, { scope, collection, cursor });
31
+ if (page.epoch !== tree.epoch || page.omittedIds.length) {
32
+ fail('incomplete_documentation', 'The Graph change response is incomplete.');
33
+ }
34
+ for (const item of page.items) {
35
+ if (item.kind === 'remove') {
36
+ records.delete(item.id);
37
+ } else {
38
+ records.set(
39
+ item.id,
40
+ parse(recordSchema, { id: item.id, revision: item.revision, data: item.data }),
41
+ );
42
+ }
43
+ }
44
+ cursor = page.nextCursor;
45
+ if (!page.hasMore) {
46
+ return { items: [...records.values()], cursor };
47
+ }
48
+ }
49
+ fail('documentation_capacity', 'The Graph change window exceeds its request budget.');
50
+ } catch (error) {
51
+ if (!['reset_required', 'agent_change_history_expired'].includes(error.code)) {
52
+ throw error;
53
+ }
54
+ records.clear(); // Explicit server reset: replace from a fresh bounded inventory.
55
+ }
56
+ }
57
+ let after;
58
+ for (let count = 0; count < 1000; count++) {
59
+ const query = { scope, collection, after, ...contextOf(tree) };
60
+ const key = JSON.stringify(query),
61
+ bucket = downloads.graphBucket(tree);
62
+ const cached = await downloads.read(scope, bucket, key, 8388608);
63
+ const page = cached
64
+ ? JSON.parse(cached.toString('utf8'))
65
+ : await client.get('resources', query);
66
+ if (
67
+ page.scope !== scope ||
68
+ page.collection !== collection ||
69
+ page.epoch !== tree.epoch ||
70
+ page.checkpoint !== tree.checkpoint ||
71
+ !Array.isArray(page.items) ||
72
+ page.items.length > 50 ||
73
+ !Array.isArray(page.omittedIds) ||
74
+ page.omittedIds.length
75
+ ) {
76
+ fail('incomplete_documentation', 'The Graph inventory is incomplete.');
77
+ }
78
+ for (const item of page.items) {
79
+ const record = parse(recordSchema, item);
80
+ if (records.has(record.id)) {
81
+ fail('invalid_response', 'The Graph inventory repeated an item.');
82
+ }
83
+ records.set(record.id, record);
84
+ }
85
+ if (!cached) {
86
+ await downloads.put(scope, bucket, key, Buffer.from(JSON.stringify(page)));
87
+ }
88
+ if (!page.nextAfter) {
89
+ if (!page.changeCursor) {
90
+ fail('invalid_response', 'The Graph inventory has no incremental cursor.');
91
+ }
92
+ return { items: [...records.values()], cursor: page.changeCursor };
93
+ }
94
+ if (page.nextAfter === after || page.nextAfter !== page.items.at(-1)?.id) {
95
+ fail('invalid_response', 'The Graph inventory cursor did not advance.');
96
+ }
97
+ after = page.nextAfter;
98
+ }
99
+ fail('documentation_capacity', 'The Graph inventory exceeds its request budget.');
100
+ }
101
+
102
+ export async function exportGraph(client, store, source, tree, previous, state, assets, downloads) {
103
+ if (!source.graph) {
104
+ return;
105
+ }
106
+ const file = `${source.scope.replace(':', '-')}/graph.json`;
107
+ const oldBytes =
108
+ previous?.graph && equivalentAccess(previous, tree)
109
+ ? await store.readPrevious(previous.graph.file)
110
+ : null;
111
+ const oldIndex = oldBytes ? JSON.parse(oldBytes.toString('utf8')) : null;
112
+ let old = oldIndex;
113
+ if (oldIndex?.formatVersion === 2) {
114
+ old = { nodes: [], edges: [] };
115
+ for (const collection of ['nodes', 'edges']) {
116
+ const chunks = oldIndex.chunks?.[collection];
117
+ if (!Array.isArray(chunks) || chunks.length > 300) {
118
+ fail('invalid_documentation_manifest', 'The Graph chunk index is invalid.');
119
+ }
120
+ for (const path of chunks) {
121
+ const bytes = await store.readPrevious(path);
122
+ if (!bytes) {
123
+ fail('invalid_documentation_manifest', 'A tracked Graph chunk is missing.');
124
+ }
125
+ const chunk = JSON.parse(bytes.toString('utf8'));
126
+ if (!Array.isArray(chunk) || chunk.length > 100) {
127
+ fail('invalid_documentation_manifest', 'A Graph chunk exceeds its bound.');
128
+ }
129
+ old[collection].push(...chunk.map((record) => parse(recordSchema, record)));
130
+ }
131
+ }
132
+ }
133
+ const nodes = await graphCollection(
134
+ client,
135
+ source.scope,
136
+ 'nodes',
137
+ tree,
138
+ old?.nodes,
139
+ old && previous.graph.nodesCursor,
140
+ downloads,
141
+ );
142
+ const edges = await graphCollection(
143
+ client,
144
+ source.scope,
145
+ 'edges',
146
+ tree,
147
+ old?.edges,
148
+ old && previous.graph.edgesCursor,
149
+ downloads,
150
+ );
151
+ if (nodes.items.length > 10000 || edges.items.length > 30000) {
152
+ fail('documentation_capacity', 'The Graph exceeds its source capacity.');
153
+ }
154
+ const ids = new Set(nodes.items.map((node) => node.id));
155
+ if (edges.items.some((edge) => !ids.has(edge.data.source) || !ids.has(edge.data.target))) {
156
+ fail(
157
+ 'read_context_changed',
158
+ 'The Graph has missing endpoints; refresh its source before exporting.',
159
+ );
160
+ }
161
+ for (const node of nodes.items) {
162
+ if (node.data.imageAssetId) {
163
+ assets.add(parse(id, node.data.imageAssetId));
164
+ }
165
+ }
166
+ // Bounded portable chunks keep large Graphs out of one enormous JSON/Markdown
167
+ // file. Borrowed references remain identities, never cached source bodies.
168
+ const notice =
169
+ '> Automatically generated from GeniusTasker. Edit through the [MCP](https://github.com/PulsarInteractive/geniustasker-agent-tools/blob/main/docs/DOCUMENTATION_SYNC.md), then sync again.';
170
+ const footer = `Source scope: \`${source.scope}\` · Epoch: \`${tree.epoch}\` · Checkpoint: \`${tree.checkpoint}\``;
171
+ const overview = [
172
+ notice,
173
+ '',
174
+ `# ${escape(tree.title)} — Graph`,
175
+ '',
176
+ 'Linked Memory content is resolved online with current permissions. These files preserve this Graph’s own notes and qualified relations.',
177
+ '',
178
+ ];
179
+ const chunks = { nodes: [], edges: [] };
180
+ for (const [collection, records] of [
181
+ ['nodes', nodes.items],
182
+ ['edges', edges.items],
183
+ ]) {
184
+ records.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
185
+ for (let offset = 0; offset < records.length; offset += 100) {
186
+ const part = records.slice(offset, offset + 100),
187
+ name = `graph/${collection}-${String(offset / 100).padStart(4, '0')}`;
188
+ const path = `${source.scope.replace(':', '-')}/${name}`;
189
+ chunks[collection].push(path + '.json');
190
+ await store.put(path + '.json', JSON.stringify(part, null, 2) + '\n');
191
+ const text = [
192
+ notice,
193
+ '',
194
+ `# ${collection === 'nodes' ? 'Nodes' : 'Relations'} ${offset + 1}–${offset + part.length}`,
195
+ '',
196
+ ];
197
+ for (const record of part) {
198
+ if (collection === 'nodes') {
199
+ text.push(
200
+ `## ${escape(record.data.title ?? record.id)}`,
201
+ '',
202
+ String(record.data.text ?? record.data.summary ?? ''),
203
+ '',
204
+ );
205
+ for (const ref of record.data.references ?? []) {
206
+ text.push(
207
+ `- Memory reference: \`${escape(ref.scope)}\` / \`${escape(ref.pageId)}\`${ref.blockId ? ' / `' + escape(ref.blockId) + '`' : ''} (resolve current access)`,
208
+ );
209
+ }
210
+ } else {
211
+ text.push(
212
+ `## ${escape(record.data.label || record.data.relation)}`,
213
+ '',
214
+ `\`${escape(record.data.source)}\` → \`${escape(record.data.target)}\` · ${escape(record.data.certainty)}`,
215
+ '',
216
+ String(record.data.note ?? ''),
217
+ '',
218
+ );
219
+ }
220
+ text.push(
221
+ `Source ID: \`${record.id}\` · Revision: \`${record.revision}\` · Updated: ${escape(record.data.updated ?? 'unknown')} · Author: ${escape(record.data.lastEditor ?? record.data.creator ?? 'unknown')}`,
222
+ '',
223
+ );
224
+ }
225
+ text.push('', '---', '', footer, '');
226
+ await store.put(path + '.md', text.join('\n'));
227
+ overview.push(
228
+ `- [${collection === 'nodes' ? 'Nodes' : 'Relations'} ${offset + 1}–${offset + part.length}](${name}.md) · [JSON](${name}.json)`,
229
+ );
230
+ }
231
+ }
232
+ overview.push('', '---', '', footer, '');
233
+ await store.put(file.replace('.json', '.md'), overview.join('\n'));
234
+ await store.put(
235
+ file,
236
+ JSON.stringify(
237
+ {
238
+ formatVersion: 2,
239
+ scope: source.scope,
240
+ epoch: tree.epoch,
241
+ counts: { nodes: nodes.items.length, edges: edges.items.length },
242
+ chunks,
243
+ referencePolicy:
244
+ 'Resolve linked Memory content online with current access. Unavailable targets do not remove nodes.',
245
+ },
246
+ null,
247
+ 2,
248
+ ) + '\n',
249
+ );
250
+ state.graph = { file, nodesCursor: nodes.cursor, edgesCursor: edges.cursor };
251
+ }