@pulsarinteractive/geniustasker 0.1.0-beta.2 → 0.1.0-beta.4
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 +19 -0
- package/CONTRIBUTING.md +67 -12
- package/LICENSE +21 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +55 -59
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/docs/ARCHITECTURE.md +126 -0
- package/docs/CLI.md +30 -11
- package/docs/INTEGRATIONS.md +3 -3
- package/docs/LIMITS.md +49 -0
- package/docs/MEMORY.md +38 -0
- package/docs/OPENAI.md +10 -11
- package/docs/RELEASING.md +11 -3
- package/docs/TESTING.md +52 -0
- package/examples/codex.toml +1 -1
- package/examples/mcp.json +1 -7
- package/mcp.json +1 -7
- package/package.json +20 -9
- package/plugin.json +6 -15
- package/skills/geniustasker/SKILL.md +3 -2
- package/skills/geniustasker/agents/openai.yaml +3 -3
- package/skills/geniustasker/references/collaboration.md +0 -2
- package/skills/geniustasker/references/project-reads.md +43 -0
- package/skills/geniustasker-memory/SKILL.md +67 -6
- package/skills/geniustasker-memory/agents/openai.yaml +3 -3
- package/skills/geniustasker-memory/references/knowledge-workflows.md +57 -0
- package/src/changes.mjs +104 -28
- package/src/cli.mjs +372 -77
- package/src/client.mjs +201 -34
- package/src/command-catalog.mjs +109 -5
- package/src/commands.mjs +215 -52
- package/src/errors.mjs +55 -12
- package/src/knowledge.mjs +109 -0
- package/src/login.mjs +190 -47
- package/src/mcp.mjs +256 -43
- package/src/media.mjs +242 -0
- package/src/profiles.mjs +123 -32
- package/src/read-cache.mjs +111 -0
- package/src/store.mjs +101 -26
- package/src/transport.mjs +165 -32
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 =
|
|
20
|
-
|
|
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)
|
|
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),
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 (
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
command.kind === 'memory.create' &&
|
|
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 Memory with a UUID v4 as id, the same initial epoch, and scope memory:<id>. 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() {
|
|
39
|
-
|
|
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')
|
|
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)
|
|
47
|
-
|
|
48
|
-
|
|
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)
|
|
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 {
|
|
133
|
+
} finally {
|
|
134
|
+
await handle?.close();
|
|
135
|
+
}
|
|
54
136
|
}
|
|
55
|
-
const binding = session => ({
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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) {
|
|
169
|
+
constructor(client) {
|
|
170
|
+
this.client = client;
|
|
171
|
+
}
|
|
67
172
|
store(id) {
|
|
68
173
|
checked(operationIdSchema, id);
|
|
69
|
-
return new CredentialStore({
|
|
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({
|
|
73
|
-
|
|
74
|
-
|
|
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)
|
|
80
|
-
|
|
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),
|
|
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(),
|
|
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(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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', {
|
|
98
|
-
|
|
99
|
-
|
|
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, {
|
|
105
|
-
|
|
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) {
|
|
274
|
+
async retry(id) {
|
|
275
|
+
return this.execute((await this.inspect(id)).request);
|
|
276
|
+
}
|
|
114
277
|
}
|
package/src/errors.mjs
CHANGED
|
@@ -1,22 +1,65 @@
|
|
|
1
1
|
/** Never forward raw network exceptions: URLs, HTML and provider diagnostics
|
|
2
2
|
* can contain credentials. API messages are data, never terminal control. */
|
|
3
3
|
export class AgentError extends Error {
|
|
4
|
-
constructor(
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
constructor(
|
|
5
|
+
code,
|
|
6
|
+
message,
|
|
7
|
+
{ status = 0, retryable = false, remediation = 'Check help and retry.', ...details } = {},
|
|
8
|
+
) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.retryable = retryable;
|
|
13
|
+
this.remediation = remediation;
|
|
14
|
+
this.details = details;
|
|
15
|
+
}
|
|
16
|
+
toJSON() {
|
|
17
|
+
return {
|
|
18
|
+
code: this.code,
|
|
19
|
+
message: this.message,
|
|
20
|
+
retryable: this.retryable,
|
|
21
|
+
remediation: this.remediation,
|
|
22
|
+
...this.details,
|
|
23
|
+
};
|
|
7
24
|
}
|
|
8
|
-
toJSON() { return { code: this.code, message: this.message, retryable: this.retryable, remediation: this.remediation, ...this.details }; }
|
|
9
25
|
}
|
|
10
|
-
export const fail = (code, message, options) => {
|
|
26
|
+
export const fail = (code, message, options) => {
|
|
27
|
+
throw new AgentError(code, message, options);
|
|
28
|
+
};
|
|
11
29
|
export function safeError(error) {
|
|
12
|
-
return error instanceof AgentError
|
|
30
|
+
return error instanceof AgentError
|
|
31
|
+
? error
|
|
32
|
+
: new AgentError('internal_error', 'The command could not complete.', {
|
|
33
|
+
remediation: 'Retry once; run doctor if the problem persists.',
|
|
34
|
+
});
|
|
13
35
|
}
|
|
14
36
|
export function exitCode(error) {
|
|
15
|
-
if (['invalid_arguments', 'invalid_origin'].includes(error.code))
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
37
|
+
if (['invalid_arguments', 'invalid_origin'].includes(error.code)) {
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
error.status === 401 ||
|
|
42
|
+
[
|
|
43
|
+
'not_authenticated',
|
|
44
|
+
'reauthentication_required',
|
|
45
|
+
'access_denied',
|
|
46
|
+
'expired_token',
|
|
47
|
+
'invalid_grant',
|
|
48
|
+
].includes(error.code)
|
|
49
|
+
) {
|
|
50
|
+
return 3;
|
|
51
|
+
}
|
|
52
|
+
if (error.status === 403 || error.code === 'profile_selection_denied') {
|
|
53
|
+
return 4;
|
|
54
|
+
}
|
|
55
|
+
if (error.status === 409 || error.code === 'profile_selection_conflict') {
|
|
56
|
+
return 5;
|
|
57
|
+
}
|
|
58
|
+
if (error.status === 429) {
|
|
59
|
+
return 6;
|
|
60
|
+
}
|
|
61
|
+
if (error.retryable) {
|
|
62
|
+
return 7;
|
|
63
|
+
}
|
|
21
64
|
return 1;
|
|
22
65
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { fail } from './errors.mjs';
|
|
3
|
+
|
|
4
|
+
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/);
|
|
5
|
+
const scope = z.string().regex(/^memory:[a-zA-Z0-9_-]{1,128}$/);
|
|
6
|
+
const cursor = {
|
|
7
|
+
epoch: z.string().min(1).max(128).optional(),
|
|
8
|
+
checkpoint: z
|
|
9
|
+
.string()
|
|
10
|
+
.regex(/^(0|[1-9][0-9]*)$/)
|
|
11
|
+
.max(32)
|
|
12
|
+
.optional(),
|
|
13
|
+
};
|
|
14
|
+
export const searchSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
scope,
|
|
17
|
+
query: z.string().trim().min(1).max(200),
|
|
18
|
+
collection: z.enum(['pages', 'nodes']).optional(),
|
|
19
|
+
after: id.optional(),
|
|
20
|
+
...cursor,
|
|
21
|
+
})
|
|
22
|
+
.strict()
|
|
23
|
+
.refine((value) => !value.after || !!(value.epoch && value.checkpoint), {
|
|
24
|
+
message: 'Continuation needs epoch and checkpoint.',
|
|
25
|
+
});
|
|
26
|
+
export const graphSchema = z
|
|
27
|
+
.object({ scope, center: id, depth: z.number().int().min(0).max(3).optional(), ...cursor })
|
|
28
|
+
.strict();
|
|
29
|
+
export const referenceSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
scope,
|
|
32
|
+
pageId: id,
|
|
33
|
+
blockId: z
|
|
34
|
+
.string()
|
|
35
|
+
.regex(/^[a-zA-Z0-9_-]{1,80}$/)
|
|
36
|
+
.optional(),
|
|
37
|
+
})
|
|
38
|
+
.strict();
|
|
39
|
+
|
|
40
|
+
function input(schema, value) {
|
|
41
|
+
const parsed = schema.safeParse(value);
|
|
42
|
+
if (!parsed.success) {
|
|
43
|
+
fail(
|
|
44
|
+
'invalid_arguments',
|
|
45
|
+
'Use a memory scope and the documented search, Graph or reference parameters.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return parsed.data;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Query one bounded page without following cursors or retrying automatically. */
|
|
52
|
+
export async function searchMemory(client, value) {
|
|
53
|
+
const query = input(searchSchema, value);
|
|
54
|
+
const result = await client.get('knowledge', { ...query, mode: 'search' });
|
|
55
|
+
if (
|
|
56
|
+
result?.scope !== query.scope ||
|
|
57
|
+
result.mode !== 'search' ||
|
|
58
|
+
!Array.isArray(result.items) ||
|
|
59
|
+
result.items.length > 25
|
|
60
|
+
) {
|
|
61
|
+
fail('invalid_response', 'The search response does not match the requested scope.');
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Read a compact neighborhood; full node text remains a separate resource read. */
|
|
67
|
+
export async function exploreGraph(client, value) {
|
|
68
|
+
const query = input(graphSchema, value);
|
|
69
|
+
const result = await client.get('knowledge', { ...query, mode: 'graph' });
|
|
70
|
+
if (
|
|
71
|
+
result?.scope !== query.scope ||
|
|
72
|
+
result.mode !== 'graph' ||
|
|
73
|
+
!Array.isArray(result.nodes) ||
|
|
74
|
+
!Array.isArray(result.edges) ||
|
|
75
|
+
result.nodes.length > 80 ||
|
|
76
|
+
result.edges.length > 160
|
|
77
|
+
) {
|
|
78
|
+
fail('invalid_response', 'The Graph response does not match the requested scope or bounds.');
|
|
79
|
+
}
|
|
80
|
+
const ids = new Set(result.nodes.map((node) => node.id));
|
|
81
|
+
if (
|
|
82
|
+
!ids.has(query.center) ||
|
|
83
|
+
result.edges.some((edge) => !ids.has(edge.data?.source) || !ids.has(edge.data?.target))
|
|
84
|
+
) {
|
|
85
|
+
fail('invalid_response', 'The Graph response contains unresolved endpoints.');
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Linked content is never persisted by this client. Resolve again after access
|
|
91
|
+
* changes; unavailable content must not delete the user's node or relations. */
|
|
92
|
+
export async function resolveMemory(client, value) {
|
|
93
|
+
const query = input(referenceSchema, value);
|
|
94
|
+
const result = await client.get('reference', query);
|
|
95
|
+
if (
|
|
96
|
+
result?.scope !== query.scope ||
|
|
97
|
+
result.pageId !== query.pageId ||
|
|
98
|
+
result.blockId !== (query.blockId ?? null) ||
|
|
99
|
+
!['available', 'unavailable', 'missing'].includes(result.status) ||
|
|
100
|
+
(result.status === 'available' ? result.page?.id !== query.pageId : result.page !== null) ||
|
|
101
|
+
(result.status === 'unavailable' && (result.epoch !== null || result.accessRevision !== null))
|
|
102
|
+
) {
|
|
103
|
+
fail(
|
|
104
|
+
'invalid_response',
|
|
105
|
+
'The reference response does not match the requested target or current access.',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|