@persistmemory/cli 0.1.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/LICENSE +21 -0
- package/README.md +366 -0
- package/dist/bin.js +2863 -0
- package/dist/bin.js.map +7 -0
- package/dist/index.js +2853 -0
- package/dist/index.js.map +7 -0
- package/package.json +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2853 @@
|
|
|
1
|
+
// ../sdk-js/dist/index.js
|
|
2
|
+
function encodeQuery(params) {
|
|
3
|
+
if (!params) return "";
|
|
4
|
+
const search = new URLSearchParams();
|
|
5
|
+
for (const [key, value] of Object.entries(params)) {
|
|
6
|
+
if (value === void 0) continue;
|
|
7
|
+
if (Array.isArray(value)) {
|
|
8
|
+
for (const one of value) search.append(key, String(one));
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
search.append(key, String(value));
|
|
12
|
+
}
|
|
13
|
+
const encoded = search.toString();
|
|
14
|
+
return encoded ? `?${encoded}` : "";
|
|
15
|
+
}
|
|
16
|
+
function pageQuery(params, cursor, extra) {
|
|
17
|
+
return {
|
|
18
|
+
...params.limit !== void 0 ? { limit: params.limit } : {},
|
|
19
|
+
...cursor !== void 0 ? { cursor } : params.cursor !== void 0 ? { cursor: params.cursor } : {},
|
|
20
|
+
...extra
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
var DEFAULT_BACKOFF = { baseMs: 250, maxMs: 8e3, factor: 2, jitter: 1 };
|
|
24
|
+
function backoffMs(attempt, options = {}) {
|
|
25
|
+
const base = options.baseMs ?? DEFAULT_BACKOFF.baseMs;
|
|
26
|
+
const max = options.maxMs ?? DEFAULT_BACKOFF.maxMs;
|
|
27
|
+
const factor = options.factor ?? DEFAULT_BACKOFF.factor;
|
|
28
|
+
const jitter = clamp01(options.jitter ?? DEFAULT_BACKOFF.jitter);
|
|
29
|
+
const random = options.random ?? Math.random;
|
|
30
|
+
const exponent = Math.max(0, Math.floor(attempt) - 1);
|
|
31
|
+
const ceiling = Math.min(max, base * Math.pow(factor, exponent));
|
|
32
|
+
if (jitter <= 0) return Math.round(ceiling);
|
|
33
|
+
const fixed = ceiling * (1 - jitter);
|
|
34
|
+
return Math.round(fixed + random() * (ceiling - fixed));
|
|
35
|
+
}
|
|
36
|
+
function delayFor(args) {
|
|
37
|
+
const computed = backoffMs(args.attempt, args.options ?? {});
|
|
38
|
+
if (args.retryAfterSeconds === void 0 || !Number.isFinite(args.retryAfterSeconds)) {
|
|
39
|
+
return computed;
|
|
40
|
+
}
|
|
41
|
+
return Math.max(computed, Math.max(0, args.retryAfterSeconds) * 1e3);
|
|
42
|
+
}
|
|
43
|
+
function clamp01(value) {
|
|
44
|
+
if (!Number.isFinite(value)) return 0;
|
|
45
|
+
return Math.min(1, Math.max(0, value));
|
|
46
|
+
}
|
|
47
|
+
var PersistMemoryError = class extends Error {
|
|
48
|
+
status;
|
|
49
|
+
code;
|
|
50
|
+
fields;
|
|
51
|
+
requestId;
|
|
52
|
+
retryAfterSeconds;
|
|
53
|
+
/** Retrying this exact request could plausibly succeed. */
|
|
54
|
+
retryable = false;
|
|
55
|
+
constructor(init) {
|
|
56
|
+
super(redact(init.message));
|
|
57
|
+
this.name = new.target.name;
|
|
58
|
+
this.status = init.status;
|
|
59
|
+
this.code = init.code;
|
|
60
|
+
if (init.fields) this.fields = init.fields;
|
|
61
|
+
if (init.requestId) this.requestId = init.requestId;
|
|
62
|
+
if (init.retryAfterSeconds !== void 0) this.retryAfterSeconds = init.retryAfterSeconds;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A one-line summary safe to log.
|
|
66
|
+
*
|
|
67
|
+
* Provided so callers reach for this instead of `JSON.stringify(error)`,
|
|
68
|
+
* which walks own properties and would pick up anything a future field
|
|
69
|
+
* holds. Everything here is already server-supplied and key-free.
|
|
70
|
+
*/
|
|
71
|
+
toString() {
|
|
72
|
+
const id = this.requestId ? ` requestId=${this.requestId}` : "";
|
|
73
|
+
return `${this.name}: [${this.status} ${this.code}] ${this.message}${id}`;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
var AuthenticationError = class extends PersistMemoryError {
|
|
77
|
+
};
|
|
78
|
+
var PermissionDeniedError = class extends PersistMemoryError {
|
|
79
|
+
};
|
|
80
|
+
var NotFoundError = class extends PersistMemoryError {
|
|
81
|
+
};
|
|
82
|
+
var ValidationError = class extends PersistMemoryError {
|
|
83
|
+
};
|
|
84
|
+
var ConflictError = class extends PersistMemoryError {
|
|
85
|
+
};
|
|
86
|
+
var RateLimitError = class extends PersistMemoryError {
|
|
87
|
+
retryable = true;
|
|
88
|
+
};
|
|
89
|
+
var ServerError = class extends PersistMemoryError {
|
|
90
|
+
retryable = true;
|
|
91
|
+
};
|
|
92
|
+
var ConnectionError = class extends PersistMemoryError {
|
|
93
|
+
retryable = true;
|
|
94
|
+
constructor(message2) {
|
|
95
|
+
super({ status: 0, code: "CONNECTION_ERROR", message: message2 });
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
var TimeoutError = class extends PersistMemoryError {
|
|
99
|
+
retryable = true;
|
|
100
|
+
constructor(message2) {
|
|
101
|
+
super({ status: 0, code: "TIMEOUT", message: message2 });
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
var AbortError = class extends PersistMemoryError {
|
|
105
|
+
constructor(message2 = "The request was aborted by the caller.") {
|
|
106
|
+
super({ status: 0, code: "ABORTED", message: message2 });
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
function errorFromResponse(status2, body, headers) {
|
|
110
|
+
const envelope = body ?? {};
|
|
111
|
+
const code = typeof envelope.error?.code === "string" ? envelope.error.code : codeForStatus(status2);
|
|
112
|
+
const message2 = typeof envelope.error?.message === "string" && envelope.error.message.length > 0 ? envelope.error.message : defaultMessage(status2);
|
|
113
|
+
const retryAfterSeconds = status2 === 429 || status2 === 503 ? retryAfterOf(headers) : void 0;
|
|
114
|
+
const init = {
|
|
115
|
+
status: status2,
|
|
116
|
+
code,
|
|
117
|
+
message: message2,
|
|
118
|
+
...isFieldMap(envelope.error?.fields) ? { fields: envelope.error.fields } : {},
|
|
119
|
+
...typeof envelope.error?.requestId === "string" ? { requestId: envelope.error.requestId } : {},
|
|
120
|
+
...retryAfterSeconds !== void 0 ? { retryAfterSeconds } : {}
|
|
121
|
+
};
|
|
122
|
+
if (status2 === 429 || code === "RATE_LIMITED") return new RateLimitError(init);
|
|
123
|
+
if (status2 === 401 || code === "UNAUTHORIZED") return new AuthenticationError(init);
|
|
124
|
+
if (status2 === 403 || code === "FORBIDDEN") return new PermissionDeniedError(init);
|
|
125
|
+
if (status2 === 404 || code === "NOT_FOUND") return new NotFoundError(init);
|
|
126
|
+
if (status2 === 409 || code === "CONFLICT") return new ConflictError(init);
|
|
127
|
+
if (status2 === 400 || status2 === 413 || status2 === 422) return new ValidationError(init);
|
|
128
|
+
if (status2 >= 500) return new ServerError(init);
|
|
129
|
+
return new PersistMemoryError(init);
|
|
130
|
+
}
|
|
131
|
+
function retryAfterOf(headers) {
|
|
132
|
+
const raw = headers.get("retry-after");
|
|
133
|
+
if (!raw) return void 0;
|
|
134
|
+
const seconds = Number(raw.trim());
|
|
135
|
+
if (!Number.isFinite(seconds) || seconds < 0) return void 0;
|
|
136
|
+
return Math.min(seconds, 300);
|
|
137
|
+
}
|
|
138
|
+
function isFieldMap(value) {
|
|
139
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
140
|
+
return Object.values(value).every((one) => typeof one === "string");
|
|
141
|
+
}
|
|
142
|
+
function codeForStatus(status2) {
|
|
143
|
+
if (status2 === 401) return "UNAUTHORIZED";
|
|
144
|
+
if (status2 === 403) return "FORBIDDEN";
|
|
145
|
+
if (status2 === 404) return "NOT_FOUND";
|
|
146
|
+
if (status2 === 409) return "CONFLICT";
|
|
147
|
+
if (status2 === 429) return "RATE_LIMITED";
|
|
148
|
+
if (status2 === 413) return "PAYLOAD_TOO_LARGE";
|
|
149
|
+
if (status2 >= 500) return "INTERNAL_ERROR";
|
|
150
|
+
return "VALIDATION_ERROR";
|
|
151
|
+
}
|
|
152
|
+
function defaultMessage(status2) {
|
|
153
|
+
return `The API returned ${status2} with no readable error body.`;
|
|
154
|
+
}
|
|
155
|
+
function redact(text) {
|
|
156
|
+
return text.replace(/pm_(live|test)_[A-Za-z0-9_-]+/g, "pm_$1_[redacted]");
|
|
157
|
+
}
|
|
158
|
+
var DEFAULT_BASE_URL = "https://api.persistmemory.com";
|
|
159
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
160
|
+
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
161
|
+
var RETRY_WITHOUT_ASKING = /* @__PURE__ */ new Set(["GET", "HEAD", "PATCH", "DELETE"]);
|
|
162
|
+
var HttpClient = class {
|
|
163
|
+
/**
|
|
164
|
+
* The credential, in a private field, and never anywhere else.
|
|
165
|
+
*
|
|
166
|
+
* Private (`#`) rather than `readonly`: a public field is enumerable, so
|
|
167
|
+
* `JSON.stringify(client)` and every structured logger that walks own
|
|
168
|
+
* properties would write the key into a log line. `toJSON` and the inspect
|
|
169
|
+
* hook below close the two remaining paths - a bug report pasted from
|
|
170
|
+
* `console.log(client)` is exactly how a key gets shared with strangers.
|
|
171
|
+
*/
|
|
172
|
+
#apiKey;
|
|
173
|
+
#baseUrl;
|
|
174
|
+
#fetch;
|
|
175
|
+
#timeoutMs;
|
|
176
|
+
#maxAttempts;
|
|
177
|
+
#backoff;
|
|
178
|
+
#sleep;
|
|
179
|
+
#userAgent;
|
|
180
|
+
constructor(options) {
|
|
181
|
+
if (typeof options.apiKey !== "string" || options.apiKey.trim().length === 0) {
|
|
182
|
+
throw new PersistMemoryError({
|
|
183
|
+
status: 0,
|
|
184
|
+
code: "VALIDATION_ERROR",
|
|
185
|
+
message: "An API key is required. Pass `apiKey`, or set PERSISTMEMORY_API_KEY."
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
const key = options.apiKey.trim();
|
|
189
|
+
if (/[\r\n\0]/.test(key)) {
|
|
190
|
+
throw new PersistMemoryError({
|
|
191
|
+
status: 0,
|
|
192
|
+
code: "VALIDATION_ERROR",
|
|
193
|
+
message: "The API key contains characters that cannot go in a header."
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
this.#apiKey = key;
|
|
197
|
+
this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
198
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
199
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
200
|
+
this.#maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
201
|
+
this.#backoff = options.backoff ?? {};
|
|
202
|
+
this.#sleep = options.sleep ?? defaultSleep;
|
|
203
|
+
this.#userAgent = options.userAgent ?? "persistmemory-sdk-js/0.1.0";
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* What this object looks like when something serialises it.
|
|
207
|
+
*
|
|
208
|
+
* Both hooks return the same key-free shape. `toJSON` covers
|
|
209
|
+
* `JSON.stringify`, the inspect symbol covers `console.log` under Node, and
|
|
210
|
+
* between them they cover how a credential actually escapes: not through a
|
|
211
|
+
* deliberate log line, but through an object dumped into a bug report.
|
|
212
|
+
*/
|
|
213
|
+
toJSON() {
|
|
214
|
+
return { baseUrl: this.#baseUrl, apiKey: "[redacted]" };
|
|
215
|
+
}
|
|
216
|
+
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
217
|
+
return this.toJSON();
|
|
218
|
+
}
|
|
219
|
+
async get(path, query, options) {
|
|
220
|
+
return this.#request({
|
|
221
|
+
method: "GET",
|
|
222
|
+
path,
|
|
223
|
+
...query ? { query } : {},
|
|
224
|
+
...options ? { options } : {}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async post(path, body, options) {
|
|
228
|
+
return this.#request({
|
|
229
|
+
method: "POST",
|
|
230
|
+
path,
|
|
231
|
+
...body !== void 0 ? { body } : {},
|
|
232
|
+
...options ? { options } : {}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
async patch(path, body, options) {
|
|
236
|
+
return this.#request({
|
|
237
|
+
method: "PATCH",
|
|
238
|
+
path,
|
|
239
|
+
...body !== void 0 ? { body } : {},
|
|
240
|
+
...options ? { options } : {}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
async delete(path, body, options) {
|
|
244
|
+
return this.#request({
|
|
245
|
+
method: "DELETE",
|
|
246
|
+
path,
|
|
247
|
+
...body !== void 0 ? { body } : {},
|
|
248
|
+
...options ? { options } : {}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
async #request(request) {
|
|
252
|
+
const maxAttempts = Math.max(1, request.options?.maxAttempts ?? this.#maxAttempts);
|
|
253
|
+
const url = this.#baseUrl + request.path + encodeQuery(request.query);
|
|
254
|
+
let attempt = 0;
|
|
255
|
+
for (; ; ) {
|
|
256
|
+
attempt += 1;
|
|
257
|
+
let error;
|
|
258
|
+
try {
|
|
259
|
+
return await this.#attempt(request, url);
|
|
260
|
+
} catch (thrown) {
|
|
261
|
+
if (!(thrown instanceof PersistMemoryError)) throw thrown;
|
|
262
|
+
error = thrown;
|
|
263
|
+
}
|
|
264
|
+
if (attempt >= maxAttempts) throw error;
|
|
265
|
+
if (!mayRetry(error, request)) throw error;
|
|
266
|
+
const delay = delayFor({
|
|
267
|
+
attempt,
|
|
268
|
+
...error.retryAfterSeconds !== void 0 ? { retryAfterSeconds: error.retryAfterSeconds } : {},
|
|
269
|
+
options: this.#backoff
|
|
270
|
+
});
|
|
271
|
+
await this.#sleep(delay, request.options?.signal);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async #attempt(request, url) {
|
|
275
|
+
const timeoutMs = request.options?.timeoutMs ?? this.#timeoutMs;
|
|
276
|
+
const deadline = new AbortController();
|
|
277
|
+
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
|
278
|
+
const onCallerAbort = () => deadline.abort();
|
|
279
|
+
const caller = request.options?.signal;
|
|
280
|
+
caller?.addEventListener("abort", onCallerAbort, { once: true });
|
|
281
|
+
try {
|
|
282
|
+
if (caller?.aborted) throw new AbortError();
|
|
283
|
+
const response = await untilAborted(
|
|
284
|
+
this.#fetch(url, {
|
|
285
|
+
method: request.method,
|
|
286
|
+
headers: this.#headers(request),
|
|
287
|
+
...request.body !== void 0 ? { body: JSON.stringify(request.body) } : {},
|
|
288
|
+
signal: deadline.signal
|
|
289
|
+
}),
|
|
290
|
+
deadline.signal
|
|
291
|
+
);
|
|
292
|
+
const payload = await readBody(response);
|
|
293
|
+
if (!response.ok) throw errorFromResponse(response.status, payload, response.headers);
|
|
294
|
+
return payload;
|
|
295
|
+
} catch (thrown) {
|
|
296
|
+
if (thrown instanceof PersistMemoryError) throw thrown;
|
|
297
|
+
if (caller?.aborted) throw new AbortError();
|
|
298
|
+
if (deadline.signal.aborted) {
|
|
299
|
+
throw new TimeoutError(`The request did not complete within ${timeoutMs}ms.`);
|
|
300
|
+
}
|
|
301
|
+
const detail = thrown instanceof Error ? redact(thrown.message) : "transport failure";
|
|
302
|
+
throw new ConnectionError(`Could not reach the API: ${detail}`);
|
|
303
|
+
} finally {
|
|
304
|
+
clearTimeout(timer);
|
|
305
|
+
caller?.removeEventListener("abort", onCallerAbort);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
#headers(request) {
|
|
309
|
+
return {
|
|
310
|
+
// The only place the key is ever read.
|
|
311
|
+
authorization: `Bearer ${this.#apiKey}`,
|
|
312
|
+
accept: "application/json",
|
|
313
|
+
"user-agent": this.#userAgent,
|
|
314
|
+
...request.body !== void 0 ? { "content-type": "application/json" } : {},
|
|
315
|
+
...request.options?.idempotencyKey ? { "idempotency-key": request.options.idempotencyKey } : {}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
function mayRetry(error, request) {
|
|
320
|
+
if (!error.retryable) return false;
|
|
321
|
+
if (RETRY_WITHOUT_ASKING.has(request.method)) return true;
|
|
322
|
+
if (request.options?.idempotencyKey) return true;
|
|
323
|
+
return error.status === 429;
|
|
324
|
+
}
|
|
325
|
+
async function readBody(response) {
|
|
326
|
+
if (response.status === 204) return void 0;
|
|
327
|
+
const text = await response.text().catch(() => "");
|
|
328
|
+
if (text.length === 0) return void 0;
|
|
329
|
+
const type = response.headers.get("content-type") ?? "";
|
|
330
|
+
if (!type.includes("json")) return { raw: text.slice(0, 500) };
|
|
331
|
+
try {
|
|
332
|
+
return JSON.parse(text);
|
|
333
|
+
} catch {
|
|
334
|
+
return { raw: text.slice(0, 500) };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
var SignalFired = class extends Error {
|
|
338
|
+
constructor() {
|
|
339
|
+
super("signal fired");
|
|
340
|
+
this.name = "SignalFired";
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
function untilAborted(work, signal) {
|
|
344
|
+
work.catch(() => void 0);
|
|
345
|
+
return new Promise((resolve5, reject) => {
|
|
346
|
+
if (signal.aborted) {
|
|
347
|
+
reject(new SignalFired());
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const onAbort = () => reject(new SignalFired());
|
|
351
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
352
|
+
work.then(
|
|
353
|
+
(value) => {
|
|
354
|
+
signal.removeEventListener("abort", onAbort);
|
|
355
|
+
resolve5(value);
|
|
356
|
+
},
|
|
357
|
+
(error) => {
|
|
358
|
+
signal.removeEventListener("abort", onAbort);
|
|
359
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
360
|
+
}
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
function defaultSleep(ms, signal) {
|
|
365
|
+
return new Promise((resolve5, reject) => {
|
|
366
|
+
if (signal?.aborted) {
|
|
367
|
+
reject(new AbortError());
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const timer = setTimeout(() => {
|
|
371
|
+
signal?.removeEventListener("abort", onAbort);
|
|
372
|
+
resolve5();
|
|
373
|
+
}, ms);
|
|
374
|
+
function onAbort() {
|
|
375
|
+
clearTimeout(timer);
|
|
376
|
+
reject(new AbortError());
|
|
377
|
+
}
|
|
378
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
var Paginated = class {
|
|
382
|
+
#fetchPage;
|
|
383
|
+
constructor(fetchPage) {
|
|
384
|
+
this.#fetchPage = fetchPage;
|
|
385
|
+
}
|
|
386
|
+
/** The first page, and nothing more. For a UI that renders one page at a time. */
|
|
387
|
+
async first() {
|
|
388
|
+
return this.#fetchPage(void 0);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Page by page, for a caller that wants the cursors or wants to stop early.
|
|
392
|
+
*
|
|
393
|
+
* A generator rather than an array of pages: fetching them all up front
|
|
394
|
+
* would make "show me the first ten" cost every page in the account.
|
|
395
|
+
*/
|
|
396
|
+
async *pages() {
|
|
397
|
+
let cursor;
|
|
398
|
+
const seen = /* @__PURE__ */ new Set();
|
|
399
|
+
for (; ; ) {
|
|
400
|
+
const page = await this.#fetchPage(cursor);
|
|
401
|
+
yield page;
|
|
402
|
+
const next = page.pagination.nextCursor;
|
|
403
|
+
if (!next) return;
|
|
404
|
+
if (seen.has(next)) return;
|
|
405
|
+
seen.add(next);
|
|
406
|
+
cursor = next;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** Every item across every page. `for await (const memory of ...)`. */
|
|
410
|
+
async *[Symbol.asyncIterator]() {
|
|
411
|
+
for await (const page of this.pages()) {
|
|
412
|
+
for (const item of page.data) yield item;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Everything, in one array.
|
|
417
|
+
*
|
|
418
|
+
* `maxItems` is not optional, and that is the point. An unbounded `all()` on
|
|
419
|
+
* an account with two hundred thousand memories is a request loop that runs
|
|
420
|
+
* for minutes and an array that exhausts the heap, and the call site that
|
|
421
|
+
* does it reads as innocently as any other. Ask for a number you can hold.
|
|
422
|
+
*/
|
|
423
|
+
async all(maxItems) {
|
|
424
|
+
const collected = [];
|
|
425
|
+
if (maxItems <= 0) return collected;
|
|
426
|
+
for await (const item of this) {
|
|
427
|
+
collected.push(item);
|
|
428
|
+
if (collected.length >= maxItems) break;
|
|
429
|
+
}
|
|
430
|
+
return collected;
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
var Memories = class {
|
|
434
|
+
#http;
|
|
435
|
+
constructor(http) {
|
|
436
|
+
this.#http = http;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* One page, plus the cursor loop.
|
|
440
|
+
*
|
|
441
|
+
* Returns a `Paginated`, so `await memories.list().first()` gets a page and
|
|
442
|
+
* `for await (const memory of memories.list())` walks the lot. The filters
|
|
443
|
+
* are carried into every page automatically - a caller re-passing them per
|
|
444
|
+
* page is a caller who will eventually forget one, and the pages after that
|
|
445
|
+
* come from a differently filtered list.
|
|
446
|
+
*/
|
|
447
|
+
list(params = {}, options) {
|
|
448
|
+
return new Paginated(
|
|
449
|
+
(cursor) => this.#http.get(
|
|
450
|
+
"/api/v1/memories",
|
|
451
|
+
pageQuery(params, cursor, toQuery(params)),
|
|
452
|
+
options
|
|
453
|
+
)
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
async get(id, options) {
|
|
457
|
+
return this.#http.get(`/api/v1/memories/${encodeURIComponent(id)}`, void 0, options);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Hands material to the ingestion pipeline. Needs a key with `write` scope.
|
|
461
|
+
*
|
|
462
|
+
* This does NOT create a memory, and the return type says so: it answers 202
|
|
463
|
+
* with a job id. Extraction, entity resolution, deduplication and conflict
|
|
464
|
+
* detection all run afterwards and may produce one memory, several, or none.
|
|
465
|
+
* Poll `client.jobs.get(result.jobId)` to find out which.
|
|
466
|
+
*
|
|
467
|
+
* Pass an `idempotencyKey` if this can be retried by anything - a queue, a
|
|
468
|
+
* user pressing a button twice, or this client's own retry loop, which
|
|
469
|
+
* refuses to repeat a POST without one. `remember:note-42`, not a fresh
|
|
470
|
+
* random value per call.
|
|
471
|
+
*/
|
|
472
|
+
async remember(params, options) {
|
|
473
|
+
return this.#http.post("/api/v1/remember", params, options);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
function toQuery(params) {
|
|
477
|
+
return {
|
|
478
|
+
...params.type !== void 0 ? { type: params.type } : {},
|
|
479
|
+
...params.state !== void 0 ? { state: params.state } : {},
|
|
480
|
+
...params.scope !== void 0 ? { scope: params.scope } : {},
|
|
481
|
+
...params.spaceIds !== void 0 ? { spaceIds: params.spaceIds } : {},
|
|
482
|
+
...params.createdAfter !== void 0 ? { createdAfter: params.createdAfter } : {},
|
|
483
|
+
...params.createdBefore !== void 0 ? { createdBefore: params.createdBefore } : {},
|
|
484
|
+
...params.minConfidence !== void 0 ? { minConfidence: params.minConfidence } : {},
|
|
485
|
+
...params.includeHistorical !== void 0 ? { includeHistorical: params.includeHistorical } : {}
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
var Search = class {
|
|
489
|
+
#http;
|
|
490
|
+
constructor(http) {
|
|
491
|
+
this.#http = http;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Ranked results, with an account of how they were found.
|
|
495
|
+
*
|
|
496
|
+
* Read `diagnostics.degraded` before showing the results. Search degrades
|
|
497
|
+
* rather than fails - with embeddings unavailable it falls back to
|
|
498
|
+
* deterministic retrieval and still answers - and a UI that cannot tell
|
|
499
|
+
* degraded from healthy tells the user the system knows nothing when it is
|
|
500
|
+
* merely looking with one eye.
|
|
501
|
+
*/
|
|
502
|
+
async query(params, options) {
|
|
503
|
+
const query = {
|
|
504
|
+
query: params.query,
|
|
505
|
+
...params.limit !== void 0 ? { limit: params.limit } : {},
|
|
506
|
+
...params.scope !== void 0 ? { scope: params.scope } : {},
|
|
507
|
+
...params.spaceIds !== void 0 ? { spaceIds: params.spaceIds } : {},
|
|
508
|
+
...params.types !== void 0 ? { types: params.types } : {},
|
|
509
|
+
...params.minScore !== void 0 ? { minScore: params.minScore } : {},
|
|
510
|
+
...params.asOf !== void 0 ? { asOf: params.asOf } : {},
|
|
511
|
+
...params.includeHistorical !== void 0 ? { includeHistorical: params.includeHistorical } : {},
|
|
512
|
+
...params.includeEvidence !== void 0 ? { includeEvidence: params.includeEvidence } : {},
|
|
513
|
+
...params.explain !== void 0 ? { explain: params.explain } : {}
|
|
514
|
+
};
|
|
515
|
+
return this.#http.get("/api/v1/search", query, options);
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* A context window, assembled and ready to paste into a prompt.
|
|
519
|
+
*
|
|
520
|
+
* A POST that is safe to repeat - it reads and returns, it writes nothing -
|
|
521
|
+
* so this is one of the few places where retrying without an idempotency key
|
|
522
|
+
* would be harmless. It still is not retried by default: the request loop
|
|
523
|
+
* decides by METHOD, and a per-endpoint exception is a rule that holds until
|
|
524
|
+
* someone adds a POST next to it that does write.
|
|
525
|
+
*/
|
|
526
|
+
async context(params, options) {
|
|
527
|
+
return this.#http.post("/api/v1/context", params, options);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
var Spaces = class {
|
|
531
|
+
#http;
|
|
532
|
+
constructor(http) {
|
|
533
|
+
this.#http = http;
|
|
534
|
+
}
|
|
535
|
+
list(params = {}, options) {
|
|
536
|
+
return new Paginated(
|
|
537
|
+
(cursor) => this.#http.get(
|
|
538
|
+
"/api/v1/spaces",
|
|
539
|
+
pageQuery(params, cursor, {
|
|
540
|
+
...params.includeArchived !== void 0 ? { includeArchived: params.includeArchived } : {}
|
|
541
|
+
}),
|
|
542
|
+
options
|
|
543
|
+
)
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
async get(id, options) {
|
|
547
|
+
return this.#http.get(`/api/v1/spaces/${encodeURIComponent(id)}`, void 0, options);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Creates a Space. Answers 201.
|
|
551
|
+
*
|
|
552
|
+
* Worth an idempotency key when a person is behind it: a double-clicked
|
|
553
|
+
* "create Space" button makes two Spaces called Work, and nothing later can
|
|
554
|
+
* tell which of them memories should have gone into.
|
|
555
|
+
*/
|
|
556
|
+
async create(params, options) {
|
|
557
|
+
return this.#http.post("/api/v1/spaces", params, options);
|
|
558
|
+
}
|
|
559
|
+
/** Renaming, retention, and archiving - `archived` is a field, not a verb. */
|
|
560
|
+
async update(id, params, options) {
|
|
561
|
+
return this.#http.patch(`/api/v1/spaces/${encodeURIComponent(id)}`, params, options);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* The memories filed in a Space.
|
|
565
|
+
*
|
|
566
|
+
* This endpoint answers `{ data, pagination: { limit } }` with no cursor: it
|
|
567
|
+
* returns the first `limit` members and stops. Wrapped in a `Paginated`
|
|
568
|
+
* anyway so it reads like every other list, and it simply yields one page -
|
|
569
|
+
* a caller who needs more should filter `memories.list` by `spaceIds`, which
|
|
570
|
+
* is the endpoint that actually pages.
|
|
571
|
+
*/
|
|
572
|
+
memories(id, params = {}, options) {
|
|
573
|
+
return new Paginated(
|
|
574
|
+
() => this.#http.get(
|
|
575
|
+
`/api/v1/spaces/${encodeURIComponent(id)}/memories`,
|
|
576
|
+
{ ...params.limit !== void 0 ? { limit: params.limit } : {} },
|
|
577
|
+
options
|
|
578
|
+
)
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
async addMemories(id, memoryIds, options) {
|
|
582
|
+
return this.#http.post(
|
|
583
|
+
`/api/v1/spaces/${encodeURIComponent(id)}/memories`,
|
|
584
|
+
{ memoryIds },
|
|
585
|
+
options
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Removes memberships. The memories themselves are untouched.
|
|
590
|
+
*
|
|
591
|
+
* A body on a DELETE, which is unusual and is what the API takes: the
|
|
592
|
+
* alternative is five hundred ids in a query string, and every proxy in
|
|
593
|
+
* between has its own limit on how long a URL may be.
|
|
594
|
+
*/
|
|
595
|
+
async removeMemories(id, memoryIds, options) {
|
|
596
|
+
return this.#http.delete(
|
|
597
|
+
`/api/v1/spaces/${encodeURIComponent(id)}/memories`,
|
|
598
|
+
{ memoryIds },
|
|
599
|
+
options
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
var Sources = class {
|
|
604
|
+
#http;
|
|
605
|
+
constructor(http) {
|
|
606
|
+
this.#http = http;
|
|
607
|
+
}
|
|
608
|
+
list(params = {}, options) {
|
|
609
|
+
return new Paginated(
|
|
610
|
+
(cursor) => this.#http.get(
|
|
611
|
+
"/api/v1/sources",
|
|
612
|
+
pageQuery(params, cursor, {
|
|
613
|
+
...params.provider !== void 0 ? { provider: params.provider } : {}
|
|
614
|
+
}),
|
|
615
|
+
options
|
|
616
|
+
)
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
async get(id, options) {
|
|
620
|
+
return this.#http.get(`/api/v1/sources/${encodeURIComponent(id)}`, void 0, options);
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
var Documents = class {
|
|
624
|
+
#http;
|
|
625
|
+
constructor(http) {
|
|
626
|
+
this.#http = http;
|
|
627
|
+
}
|
|
628
|
+
list(params = {}, options) {
|
|
629
|
+
return new Paginated(
|
|
630
|
+
(cursor) => this.#http.get(
|
|
631
|
+
"/api/v1/documents",
|
|
632
|
+
pageQuery(params, cursor, {
|
|
633
|
+
...params.sourceId !== void 0 ? { sourceId: params.sourceId } : {},
|
|
634
|
+
...params.status !== void 0 ? { status: params.status } : {}
|
|
635
|
+
}),
|
|
636
|
+
options
|
|
637
|
+
)
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
async get(id, options) {
|
|
641
|
+
return this.#http.get(
|
|
642
|
+
`/api/v1/documents/${encodeURIComponent(id)}`,
|
|
643
|
+
void 0,
|
|
644
|
+
options
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
var Jobs = class {
|
|
649
|
+
#http;
|
|
650
|
+
constructor(http) {
|
|
651
|
+
this.#http = http;
|
|
652
|
+
}
|
|
653
|
+
list(params = {}, options) {
|
|
654
|
+
return new Paginated(
|
|
655
|
+
(cursor) => this.#http.get(
|
|
656
|
+
"/api/v1/jobs",
|
|
657
|
+
pageQuery(params, cursor, {
|
|
658
|
+
...params.status !== void 0 ? { status: params.status } : {},
|
|
659
|
+
...params.type !== void 0 ? { type: params.type } : {}
|
|
660
|
+
}),
|
|
661
|
+
options
|
|
662
|
+
)
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* One job, by id. This is what `remember` hands back a reference to.
|
|
667
|
+
*
|
|
668
|
+
* `completed` is the terminal success state - the store's own word, not
|
|
669
|
+
* `succeeded`. A caller polling for a state the API never writes waits
|
|
670
|
+
* forever with nothing to show why.
|
|
671
|
+
*/
|
|
672
|
+
async get(id, options) {
|
|
673
|
+
return this.#http.get(`/api/v1/jobs/${encodeURIComponent(id)}`, void 0, options);
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
var Entities = class {
|
|
677
|
+
#http;
|
|
678
|
+
constructor(http) {
|
|
679
|
+
this.#http = http;
|
|
680
|
+
}
|
|
681
|
+
list(params = {}, options) {
|
|
682
|
+
return new Paginated(
|
|
683
|
+
(cursor) => this.#http.get(
|
|
684
|
+
"/api/v1/entities",
|
|
685
|
+
pageQuery(params, cursor, {
|
|
686
|
+
...params.type !== void 0 ? { type: params.type } : {},
|
|
687
|
+
...params.q !== void 0 ? { q: params.q } : {}
|
|
688
|
+
}),
|
|
689
|
+
options
|
|
690
|
+
)
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
async get(id, options) {
|
|
694
|
+
return this.#http.get(`/api/v1/entities/${encodeURIComponent(id)}`, void 0, options);
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Which memories mention this entity, and how.
|
|
698
|
+
*
|
|
699
|
+
* Returns MENTIONS - a memory id, a role and a confidence - not the memories
|
|
700
|
+
* themselves. "Memories about Sam" and "memories Sam appears in" are
|
|
701
|
+
* different questions, and `role` is what separates them.
|
|
702
|
+
*
|
|
703
|
+
* Like the Space membership list, this endpoint answers with a `pagination`
|
|
704
|
+
* block that carries no cursor, so it yields one page and stops.
|
|
705
|
+
*/
|
|
706
|
+
memories(id, params = {}, options) {
|
|
707
|
+
return new Paginated(
|
|
708
|
+
(cursor) => this.#http.get(
|
|
709
|
+
`/api/v1/entities/${encodeURIComponent(id)}/memories`,
|
|
710
|
+
pageQuery(params, cursor, {
|
|
711
|
+
...params.role !== void 0 ? { role: params.role } : {},
|
|
712
|
+
...params.minConfidence !== void 0 ? { minConfidence: params.minConfidence } : {}
|
|
713
|
+
}),
|
|
714
|
+
options
|
|
715
|
+
)
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
var Graph = class {
|
|
720
|
+
#http;
|
|
721
|
+
constructor(http) {
|
|
722
|
+
this.#http = http;
|
|
723
|
+
}
|
|
724
|
+
async traverse(params, options) {
|
|
725
|
+
return this.#http.get(
|
|
726
|
+
"/api/v1/graph",
|
|
727
|
+
{
|
|
728
|
+
from: params.from,
|
|
729
|
+
...params.depth !== void 0 ? { depth: params.depth } : {},
|
|
730
|
+
...params.maxNodes !== void 0 ? { maxNodes: params.maxNodes } : {},
|
|
731
|
+
...params.memoryLimit !== void 0 ? { memoryLimit: params.memoryLimit } : {}
|
|
732
|
+
},
|
|
733
|
+
options
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
var Conflicts = class {
|
|
738
|
+
#http;
|
|
739
|
+
constructor(http) {
|
|
740
|
+
this.#http = http;
|
|
741
|
+
}
|
|
742
|
+
list(params = {}, options) {
|
|
743
|
+
return new Paginated(
|
|
744
|
+
(cursor) => this.#http.get(
|
|
745
|
+
"/api/v1/conflicts",
|
|
746
|
+
pageQuery(params, cursor, {
|
|
747
|
+
...params.includeResolved !== void 0 ? { includeResolved: params.includeResolved } : {},
|
|
748
|
+
...params.type !== void 0 ? { type: params.type } : {}
|
|
749
|
+
}),
|
|
750
|
+
options
|
|
751
|
+
)
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
async get(id, options) {
|
|
755
|
+
return this.#http.get(
|
|
756
|
+
`/api/v1/conflicts/${encodeURIComponent(id)}`,
|
|
757
|
+
void 0,
|
|
758
|
+
options
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Settles one.
|
|
763
|
+
*
|
|
764
|
+
* `keep` names the winner and supersedes the loser; it does not delete it.
|
|
765
|
+
* `dismiss` records that the detector was wrong, which is worth knowing when
|
|
766
|
+
* the same pair trips it again.
|
|
767
|
+
*
|
|
768
|
+
* The parameter type is a union, so `keep` without a `keepId` does not
|
|
769
|
+
* compile. The server rejects it too - this just moves the failure from a
|
|
770
|
+
* 400 in production to a red squiggle.
|
|
771
|
+
*/
|
|
772
|
+
async resolve(id, params, options) {
|
|
773
|
+
return this.#http.post(
|
|
774
|
+
`/api/v1/conflicts/${encodeURIComponent(id)}/resolve`,
|
|
775
|
+
params,
|
|
776
|
+
options
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
var Conversations = class {
|
|
781
|
+
#http;
|
|
782
|
+
constructor(http) {
|
|
783
|
+
this.#http = http;
|
|
784
|
+
}
|
|
785
|
+
list(params = {}, options) {
|
|
786
|
+
return new Paginated(
|
|
787
|
+
(cursor) => this.#http.get(
|
|
788
|
+
"/api/v1/conversations",
|
|
789
|
+
pageQuery(params, cursor, {
|
|
790
|
+
...params.channel !== void 0 ? { channel: params.channel } : {}
|
|
791
|
+
}),
|
|
792
|
+
options
|
|
793
|
+
)
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
async get(id, options) {
|
|
797
|
+
return this.#http.get(
|
|
798
|
+
`/api/v1/conversations/${encodeURIComponent(id)}`,
|
|
799
|
+
void 0,
|
|
800
|
+
options
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
async create(params = {}, options) {
|
|
804
|
+
return this.#http.post("/api/v1/conversations", params, options);
|
|
805
|
+
}
|
|
806
|
+
messages(id, params = {}, options) {
|
|
807
|
+
return new Paginated(
|
|
808
|
+
(cursor) => this.#http.get(
|
|
809
|
+
`/api/v1/conversations/${encodeURIComponent(id)}/messages`,
|
|
810
|
+
pageQuery(params, cursor, {}),
|
|
811
|
+
options
|
|
812
|
+
)
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Appends turns, and by default extracts memories from them.
|
|
817
|
+
*
|
|
818
|
+
* Check `extracting` on the result. With no queue configured the turns are
|
|
819
|
+
* stored and never become memory, and the API says so in `note` rather than
|
|
820
|
+
* reporting a success - a caller that ignores it believes a memory is on its
|
|
821
|
+
* way that never arrives.
|
|
822
|
+
*
|
|
823
|
+
* `system` and `tool` turns are stored but never extracted: a system prompt
|
|
824
|
+
* is configuration, and remembering it would file our own instructions as
|
|
825
|
+
* the user's facts.
|
|
826
|
+
*
|
|
827
|
+
* Give this an `idempotencyKey`. Appending the same turn twice is the most
|
|
828
|
+
* likely duplicate in the whole API - a client reconnecting after a dropped
|
|
829
|
+
* response has no other way to tell whether its last write landed.
|
|
830
|
+
*/
|
|
831
|
+
async append(id, params, options) {
|
|
832
|
+
return this.#http.post(
|
|
833
|
+
`/api/v1/conversations/${encodeURIComponent(id)}/messages`,
|
|
834
|
+
params,
|
|
835
|
+
options
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
var Integrations = class {
|
|
840
|
+
#http;
|
|
841
|
+
constructor(http) {
|
|
842
|
+
this.#http = http;
|
|
843
|
+
}
|
|
844
|
+
list(params = {}, options) {
|
|
845
|
+
return new Paginated(
|
|
846
|
+
(cursor) => this.#http.get(
|
|
847
|
+
"/api/v1/integrations",
|
|
848
|
+
pageQuery(params, cursor, {
|
|
849
|
+
...params.provider !== void 0 ? { provider: params.provider } : {},
|
|
850
|
+
...params.status !== void 0 ? { status: params.status } : {}
|
|
851
|
+
}),
|
|
852
|
+
options
|
|
853
|
+
)
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
/** Providers that can be connected at all. Not the user's own connections. */
|
|
857
|
+
async available(options) {
|
|
858
|
+
return this.#http.get("/api/v1/integrations/available", void 0, options);
|
|
859
|
+
}
|
|
860
|
+
async get(id, options) {
|
|
861
|
+
return this.#http.get(
|
|
862
|
+
`/api/v1/integrations/${encodeURIComponent(id)}`,
|
|
863
|
+
void 0,
|
|
864
|
+
options
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
async connect(params, options) {
|
|
868
|
+
return this.#http.post(
|
|
869
|
+
"/api/v1/integrations/connect",
|
|
870
|
+
params,
|
|
871
|
+
options
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
async update(id, params, options) {
|
|
875
|
+
return this.#http.patch(
|
|
876
|
+
`/api/v1/integrations/${encodeURIComponent(id)}`,
|
|
877
|
+
params,
|
|
878
|
+
options
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Asks for a sync now rather than waiting for the schedule. Answers 202.
|
|
883
|
+
*
|
|
884
|
+
* `full` re-reads everything and is deliberately opt-in: on a large Drive
|
|
885
|
+
* that is thousands of documents and a real bill. The incremental default is
|
|
886
|
+
* what should run almost always.
|
|
887
|
+
*/
|
|
888
|
+
async sync(id, params = {}, options) {
|
|
889
|
+
return this.#http.post(
|
|
890
|
+
`/api/v1/integrations/${encodeURIComponent(id)}/sync`,
|
|
891
|
+
params,
|
|
892
|
+
options
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Destroys the credentials. The row stays.
|
|
897
|
+
*
|
|
898
|
+
* History still has to attribute the memories this connection produced, and
|
|
899
|
+
* deleting the row would leave them pointing at nothing.
|
|
900
|
+
*/
|
|
901
|
+
async disconnect(id, options) {
|
|
902
|
+
return this.#http.delete(
|
|
903
|
+
`/api/v1/integrations/${encodeURIComponent(id)}`,
|
|
904
|
+
void 0,
|
|
905
|
+
options
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
var Health = class {
|
|
910
|
+
#http;
|
|
911
|
+
constructor(http) {
|
|
912
|
+
this.#http = http;
|
|
913
|
+
}
|
|
914
|
+
async live(options) {
|
|
915
|
+
return this.#http.get("/health/live", void 0, options);
|
|
916
|
+
}
|
|
917
|
+
async ready(options) {
|
|
918
|
+
return this.#http.get("/health/ready", void 0, options);
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
var PersistMemory = class {
|
|
922
|
+
memories;
|
|
923
|
+
search;
|
|
924
|
+
spaces;
|
|
925
|
+
sources;
|
|
926
|
+
documents;
|
|
927
|
+
jobs;
|
|
928
|
+
entities;
|
|
929
|
+
graph;
|
|
930
|
+
conflicts;
|
|
931
|
+
conversations;
|
|
932
|
+
integrations;
|
|
933
|
+
health;
|
|
934
|
+
#http;
|
|
935
|
+
constructor(options) {
|
|
936
|
+
this.#http = new HttpClient(options);
|
|
937
|
+
this.memories = new Memories(this.#http);
|
|
938
|
+
this.search = new Search(this.#http);
|
|
939
|
+
this.spaces = new Spaces(this.#http);
|
|
940
|
+
this.sources = new Sources(this.#http);
|
|
941
|
+
this.documents = new Documents(this.#http);
|
|
942
|
+
this.jobs = new Jobs(this.#http);
|
|
943
|
+
this.entities = new Entities(this.#http);
|
|
944
|
+
this.graph = new Graph(this.#http);
|
|
945
|
+
this.conflicts = new Conflicts(this.#http);
|
|
946
|
+
this.conversations = new Conversations(this.#http);
|
|
947
|
+
this.integrations = new Integrations(this.#http);
|
|
948
|
+
this.health = new Health(this.#http);
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* An escape hatch for an endpoint this package has not caught up with.
|
|
952
|
+
*
|
|
953
|
+
* Typed as `unknown` on purpose: a caller reaching past the typed surface is
|
|
954
|
+
* taking responsibility for the shape, and handing them `any` would let that
|
|
955
|
+
* responsibility spread silently through their codebase.
|
|
956
|
+
*/
|
|
957
|
+
async request(method, path, body, options) {
|
|
958
|
+
if (method === "GET") return this.#http.get(path, void 0, options);
|
|
959
|
+
if (method === "POST") return this.#http.post(path, body, options);
|
|
960
|
+
if (method === "PATCH") return this.#http.patch(path, body, options);
|
|
961
|
+
return this.#http.delete(path, body, options);
|
|
962
|
+
}
|
|
963
|
+
/** Never the key. See `HttpClient.toJSON`, which this delegates to. */
|
|
964
|
+
toJSON() {
|
|
965
|
+
return this.#http.toJSON();
|
|
966
|
+
}
|
|
967
|
+
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
968
|
+
return this.#http.toJSON();
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
// src/args.ts
|
|
973
|
+
function parseArgs(argv) {
|
|
974
|
+
const words = [];
|
|
975
|
+
const flags = {};
|
|
976
|
+
const rest = [];
|
|
977
|
+
let index = 0;
|
|
978
|
+
while (index < argv.length) {
|
|
979
|
+
const token = argv[index];
|
|
980
|
+
if (token === "--") {
|
|
981
|
+
rest.push(...argv.slice(index + 1));
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
if (token.startsWith("--")) {
|
|
985
|
+
const body = token.slice(2);
|
|
986
|
+
const equals = body.indexOf("=");
|
|
987
|
+
if (equals !== -1) {
|
|
988
|
+
flags[body.slice(0, equals)] = body.slice(equals + 1);
|
|
989
|
+
index += 1;
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
if (body.startsWith("no-")) {
|
|
993
|
+
flags[body.slice(3)] = false;
|
|
994
|
+
index += 1;
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
const next = argv[index + 1];
|
|
998
|
+
if (next !== void 0 && !next.startsWith("-")) {
|
|
999
|
+
flags[body] = next;
|
|
1000
|
+
index += 2;
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
flags[body] = true;
|
|
1004
|
+
index += 1;
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
if (token.startsWith("-") && token.length > 1) {
|
|
1008
|
+
const body = token.slice(1);
|
|
1009
|
+
const next = argv[index + 1];
|
|
1010
|
+
if (next !== void 0 && !next.startsWith("-")) {
|
|
1011
|
+
flags[body] = next;
|
|
1012
|
+
index += 2;
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
flags[body] = true;
|
|
1016
|
+
index += 1;
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
words.push(token);
|
|
1020
|
+
index += 1;
|
|
1021
|
+
}
|
|
1022
|
+
return { words, flags, rest };
|
|
1023
|
+
}
|
|
1024
|
+
function stringFlag(args, ...names) {
|
|
1025
|
+
for (const name of names) {
|
|
1026
|
+
const value = args.flags[name];
|
|
1027
|
+
if (typeof value === "string") return value;
|
|
1028
|
+
}
|
|
1029
|
+
return void 0;
|
|
1030
|
+
}
|
|
1031
|
+
function boolFlag(args, ...names) {
|
|
1032
|
+
for (const name of names) {
|
|
1033
|
+
const value = args.flags[name];
|
|
1034
|
+
if (typeof value === "boolean") return value;
|
|
1035
|
+
if (value === "true") return true;
|
|
1036
|
+
if (value === "false") return false;
|
|
1037
|
+
}
|
|
1038
|
+
return false;
|
|
1039
|
+
}
|
|
1040
|
+
function numberFlag(args, ...names) {
|
|
1041
|
+
for (const name of names) {
|
|
1042
|
+
const value = args.flags[name];
|
|
1043
|
+
if (value === void 0 || typeof value === "boolean") continue;
|
|
1044
|
+
const parsed = Number(value);
|
|
1045
|
+
if (!Number.isFinite(parsed)) return "invalid";
|
|
1046
|
+
return parsed;
|
|
1047
|
+
}
|
|
1048
|
+
return void 0;
|
|
1049
|
+
}
|
|
1050
|
+
function listFlag(args, ...names) {
|
|
1051
|
+
const raw = stringFlag(args, ...names);
|
|
1052
|
+
if (raw === void 0) return void 0;
|
|
1053
|
+
const items = raw.split(",").map((one) => one.trim()).filter((one) => one.length > 0);
|
|
1054
|
+
return items.length > 0 ? items : void 0;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// src/config.ts
|
|
1058
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
1059
|
+
import { homedir } from "node:os";
|
|
1060
|
+
import { join } from "node:path";
|
|
1061
|
+
var DEFAULT_API_URL = "https://api.persistmemory.com";
|
|
1062
|
+
var DEFAULT_PROFILE = "default";
|
|
1063
|
+
function pathsFor(home = homedir()) {
|
|
1064
|
+
const dir = process.env["PERSISTMEMORY_HOME"] ?? join(home, ".persistmemory");
|
|
1065
|
+
return {
|
|
1066
|
+
dir,
|
|
1067
|
+
config: join(dir, "config.json"),
|
|
1068
|
+
credentials: join(dir, "credentials.json")
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
function readConfig(paths) {
|
|
1072
|
+
const empty = { current: DEFAULT_PROFILE, profiles: {} };
|
|
1073
|
+
if (!existsSync(paths.config)) return empty;
|
|
1074
|
+
try {
|
|
1075
|
+
const parsed = JSON.parse(readFileSync(paths.config, "utf8"));
|
|
1076
|
+
return {
|
|
1077
|
+
current: typeof parsed.current === "string" ? parsed.current : DEFAULT_PROFILE,
|
|
1078
|
+
profiles: typeof parsed.profiles === "object" && parsed.profiles ? parsed.profiles : {}
|
|
1079
|
+
};
|
|
1080
|
+
} catch {
|
|
1081
|
+
return empty;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
function readCredentials(paths) {
|
|
1085
|
+
if (!existsSync(paths.credentials)) return {};
|
|
1086
|
+
try {
|
|
1087
|
+
return JSON.parse(readFileSync(paths.credentials, "utf8"));
|
|
1088
|
+
} catch {
|
|
1089
|
+
return {};
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
function writeSecurely(path, contents, mode) {
|
|
1093
|
+
const temporary = `${path}.tmp`;
|
|
1094
|
+
writeFileSync(temporary, contents, { mode });
|
|
1095
|
+
chmodSync(temporary, mode);
|
|
1096
|
+
renameSync(temporary, path);
|
|
1097
|
+
}
|
|
1098
|
+
function writeConfig(paths, config) {
|
|
1099
|
+
mkdirSync(paths.dir, { recursive: true, mode: 448 });
|
|
1100
|
+
writeSecurely(paths.config, `${JSON.stringify(config, null, 2)}
|
|
1101
|
+
`, 384);
|
|
1102
|
+
}
|
|
1103
|
+
function writeCredentials(paths, credentials) {
|
|
1104
|
+
mkdirSync(paths.dir, { recursive: true, mode: 448 });
|
|
1105
|
+
writeSecurely(paths.credentials, `${JSON.stringify(credentials, null, 2)}
|
|
1106
|
+
`, 384);
|
|
1107
|
+
}
|
|
1108
|
+
function resolve(args) {
|
|
1109
|
+
const env = args.env ?? process.env;
|
|
1110
|
+
const config = readConfig(args.paths);
|
|
1111
|
+
const profile = args.profileFlag ?? env["PERSISTMEMORY_PROFILE"] ?? config.current ?? DEFAULT_PROFILE;
|
|
1112
|
+
const stored = config.profiles[profile];
|
|
1113
|
+
const apiUrl = args.apiUrlFlag ?? env["PERSISTMEMORY_API_URL"] ?? stored?.apiUrl ?? DEFAULT_API_URL;
|
|
1114
|
+
const fromFlag = args.apiKeyFlag;
|
|
1115
|
+
const fromEnv = env["PERSISTMEMORY_API_KEY"];
|
|
1116
|
+
const clientId = stored?.clientId;
|
|
1117
|
+
if (fromFlag) {
|
|
1118
|
+
return {
|
|
1119
|
+
profile,
|
|
1120
|
+
apiUrl,
|
|
1121
|
+
credential: { kind: "api-key", token: fromFlag },
|
|
1122
|
+
...clientId ? { clientId } : {},
|
|
1123
|
+
fromEnvironment: true
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
if (fromEnv) {
|
|
1127
|
+
return {
|
|
1128
|
+
profile,
|
|
1129
|
+
apiUrl,
|
|
1130
|
+
credential: { kind: "api-key", token: fromEnv },
|
|
1131
|
+
...clientId ? { clientId } : {},
|
|
1132
|
+
fromEnvironment: true
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
const credential = readCredentials(args.paths)[profile];
|
|
1136
|
+
return {
|
|
1137
|
+
profile,
|
|
1138
|
+
apiUrl,
|
|
1139
|
+
...credential ? { credential } : {},
|
|
1140
|
+
...clientId ? { clientId } : {},
|
|
1141
|
+
fromEnvironment: false
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
function saveLogin(args) {
|
|
1145
|
+
const config = readConfig(args.paths);
|
|
1146
|
+
writeConfig(args.paths, {
|
|
1147
|
+
// Logging in makes that profile the current one. Anything else means a
|
|
1148
|
+
// person logs in, runs a command, and is told they are not logged in.
|
|
1149
|
+
current: args.profile,
|
|
1150
|
+
profiles: {
|
|
1151
|
+
...config.profiles,
|
|
1152
|
+
[args.profile]: {
|
|
1153
|
+
apiUrl: args.apiUrl,
|
|
1154
|
+
...args.account ? { account: args.account } : {},
|
|
1155
|
+
// Kept from the existing profile when this login did not register a
|
|
1156
|
+
// new client, so an --api-key login does not erase the browser
|
|
1157
|
+
// client id and force a re-registration on the next `pm auth login`.
|
|
1158
|
+
...args.clientId ?? config.profiles[args.profile]?.clientId ? { clientId: args.clientId ?? config.profiles[args.profile]?.clientId } : {}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
writeCredentials(args.paths, {
|
|
1163
|
+
...readCredentials(args.paths),
|
|
1164
|
+
[args.profile]: args.credential
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
function clearLogin(paths, profile) {
|
|
1168
|
+
const credentials = readCredentials(paths);
|
|
1169
|
+
if (!(profile in credentials)) return false;
|
|
1170
|
+
delete credentials[profile];
|
|
1171
|
+
writeCredentials(paths, credentials);
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1174
|
+
function maskToken(token) {
|
|
1175
|
+
if (token.length <= 8) return "*".repeat(token.length);
|
|
1176
|
+
return `${token.slice(0, 4)}\u2026${token.slice(-4)}`;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// src/output.ts
|
|
1180
|
+
var OUTPUT_FORMATS = ["table", "json", "yaml", "csv", "tsv"];
|
|
1181
|
+
function isOutputFormat(value) {
|
|
1182
|
+
return OUTPUT_FORMATS.includes(value);
|
|
1183
|
+
}
|
|
1184
|
+
function render(rows, columns, options) {
|
|
1185
|
+
switch (options.format) {
|
|
1186
|
+
case "json":
|
|
1187
|
+
return JSON.stringify(rows, null, 2);
|
|
1188
|
+
case "yaml":
|
|
1189
|
+
return toYaml(rows);
|
|
1190
|
+
case "csv":
|
|
1191
|
+
return delimited(rows, columns, ",");
|
|
1192
|
+
case "tsv":
|
|
1193
|
+
return delimited(rows, columns, " ");
|
|
1194
|
+
default:
|
|
1195
|
+
return table(rows, columns, options.width);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
function renderOne(row, fields, options) {
|
|
1199
|
+
if (options.format === "json") return JSON.stringify(row, null, 2);
|
|
1200
|
+
if (options.format === "yaml") return toYaml(row);
|
|
1201
|
+
if (options.format === "csv" || options.format === "tsv") {
|
|
1202
|
+
return delimited([row], fields, options.format === "csv" ? "," : " ");
|
|
1203
|
+
}
|
|
1204
|
+
const width = Math.max(...fields.map((field) => field.header.length));
|
|
1205
|
+
return fields.map((field) => `${field.header.padEnd(width)} ${field.value(row)}`).join("\n");
|
|
1206
|
+
}
|
|
1207
|
+
function table(rows, columns, width = process.stdout.columns || 120) {
|
|
1208
|
+
if (rows.length === 0) return "";
|
|
1209
|
+
const cells = rows.map((row) => columns.map((column) => oneLine(column.value(row))));
|
|
1210
|
+
const widths = columns.map(
|
|
1211
|
+
(column, index) => Math.max(column.header.length, ...cells.map((row) => (row[index] ?? "").length))
|
|
1212
|
+
);
|
|
1213
|
+
const separator = 2;
|
|
1214
|
+
let total = widths.reduce((sum, one) => sum + one + separator, -separator);
|
|
1215
|
+
while (total > width && Math.max(...widths) > 8) {
|
|
1216
|
+
const widest = widths.indexOf(Math.max(...widths));
|
|
1217
|
+
widths[widest] = widths[widest] - 1;
|
|
1218
|
+
total -= 1;
|
|
1219
|
+
}
|
|
1220
|
+
const line = (values) => values.map((value, index) => clip(value, widths[index]).padEnd(widths[index])).join(" ").trimEnd();
|
|
1221
|
+
return [
|
|
1222
|
+
line(columns.map((column) => column.header.toUpperCase())),
|
|
1223
|
+
...cells.map((row) => line(row))
|
|
1224
|
+
].join("\n");
|
|
1225
|
+
}
|
|
1226
|
+
function delimited(rows, columns, separator) {
|
|
1227
|
+
const escape = (value) => {
|
|
1228
|
+
const flat = oneLine(value);
|
|
1229
|
+
if (!flat.includes(separator) && !flat.includes('"') && !flat.includes("\n")) return flat;
|
|
1230
|
+
return `"${flat.replace(/"/g, '""')}"`;
|
|
1231
|
+
};
|
|
1232
|
+
return [
|
|
1233
|
+
columns.map((column) => escape(column.header)).join(separator),
|
|
1234
|
+
...rows.map((row) => columns.map((column) => escape(column.value(row))).join(separator))
|
|
1235
|
+
].join("\n");
|
|
1236
|
+
}
|
|
1237
|
+
function toYaml(value, indent = 0) {
|
|
1238
|
+
const pad = " ".repeat(indent);
|
|
1239
|
+
if (value === null || value === void 0) return "null";
|
|
1240
|
+
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
|
1241
|
+
if (typeof value === "string") return yamlString(value);
|
|
1242
|
+
if (Array.isArray(value)) {
|
|
1243
|
+
if (value.length === 0) return "[]";
|
|
1244
|
+
return value.map((item) => {
|
|
1245
|
+
const rendered = toYaml(item, indent + 2);
|
|
1246
|
+
return isBlock(item) ? `${pad}-
|
|
1247
|
+
${rendered}` : `${pad}- ${rendered}`;
|
|
1248
|
+
}).join("\n");
|
|
1249
|
+
}
|
|
1250
|
+
if (typeof value === "object") {
|
|
1251
|
+
const entries = Object.entries(value);
|
|
1252
|
+
if (entries.length === 0) return "{}";
|
|
1253
|
+
return entries.map(([key, item]) => {
|
|
1254
|
+
const rendered = toYaml(item, indent + 2);
|
|
1255
|
+
return isBlock(item) ? `${pad}${key}:
|
|
1256
|
+
${rendered}` : `${pad}${key}: ${rendered}`;
|
|
1257
|
+
}).join("\n");
|
|
1258
|
+
}
|
|
1259
|
+
return String(value);
|
|
1260
|
+
}
|
|
1261
|
+
function isBlock(value) {
|
|
1262
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
1263
|
+
return typeof value === "object" && value !== null && Object.keys(value).length > 0;
|
|
1264
|
+
}
|
|
1265
|
+
function yamlString(value) {
|
|
1266
|
+
if (value === "") return '""';
|
|
1267
|
+
if (value.includes("\n")) {
|
|
1268
|
+
return `|-
|
|
1269
|
+
${value.split("\n").map((line) => ` ${line}`).join("\n")}`;
|
|
1270
|
+
}
|
|
1271
|
+
const ambiguous = /^(y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF|null|Null|NULL|~)$/.test(
|
|
1272
|
+
value
|
|
1273
|
+
) || /^[-+]?[0-9]/.test(value) || /^[\s#&*!|>'"%@`{}[\],]/.test(value) || value.includes(": ") || value.endsWith(":");
|
|
1274
|
+
return ambiguous ? `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : value;
|
|
1275
|
+
}
|
|
1276
|
+
function oneLine(value) {
|
|
1277
|
+
return value.replace(/\s*\n\s*/g, " ").trim();
|
|
1278
|
+
}
|
|
1279
|
+
function clip(value, width) {
|
|
1280
|
+
if (value.length <= width) return value;
|
|
1281
|
+
return width <= 1 ? value.slice(0, width) : `${value.slice(0, width - 1)}\u2026`;
|
|
1282
|
+
}
|
|
1283
|
+
function shortDate(iso) {
|
|
1284
|
+
if (!iso) return "";
|
|
1285
|
+
const at = new Date(iso);
|
|
1286
|
+
return Number.isNaN(at.getTime()) ? "" : iso.slice(0, 16).replace("T", " ");
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// src/help.ts
|
|
1290
|
+
var VERSION = "0.1.0";
|
|
1291
|
+
var HELP = `
|
|
1292
|
+
pm \u2014 PersistMemory from your terminal
|
|
1293
|
+
|
|
1294
|
+
Memory that persists across every model, tool and session you use.
|
|
1295
|
+
|
|
1296
|
+
GETTING STARTED
|
|
1297
|
+
|
|
1298
|
+
pm auth login sign in with your browser
|
|
1299
|
+
pm start a session and just ask
|
|
1300
|
+
pm remember "we chose Postgres" capture something
|
|
1301
|
+
pm search "what did we choose" ask for it back
|
|
1302
|
+
|
|
1303
|
+
COMMANDS
|
|
1304
|
+
|
|
1305
|
+
auth login sign in through the browser
|
|
1306
|
+
auth login --api-key sign in with a key, for CI and headless machines
|
|
1307
|
+
|
|
1308
|
+
agent --root <dir> answer file requests from your assistants,
|
|
1309
|
+
reading only inside the folders you name
|
|
1310
|
+
auth status who am I, and does the server still accept it
|
|
1311
|
+
auth logout forget the stored credential
|
|
1312
|
+
|
|
1313
|
+
chat start an interactive session
|
|
1314
|
+
chat --resume <id> pick up an earlier session
|
|
1315
|
+
|
|
1316
|
+
remember <text> capture text
|
|
1317
|
+
remember - capture whatever is piped in
|
|
1318
|
+
remember --file <path> capture a file's contents
|
|
1319
|
+
|
|
1320
|
+
search <query> search your memory
|
|
1321
|
+
list memories the most recent memories
|
|
1322
|
+
list spaces your Spaces
|
|
1323
|
+
get memory <id> one memory, in full
|
|
1324
|
+
|
|
1325
|
+
status is the service healthy
|
|
1326
|
+
requests file requests waiting for you to approve
|
|
1327
|
+
|
|
1328
|
+
FLAGS
|
|
1329
|
+
|
|
1330
|
+
--output table|json|yaml|csv|tsv how to print (default: table on a
|
|
1331
|
+
terminal, json when piped)
|
|
1332
|
+
--profile <name> use a named account
|
|
1333
|
+
--api-url <url> talk to a different server
|
|
1334
|
+
--limit <n> how many results
|
|
1335
|
+
--space <a,b> restrict to Spaces
|
|
1336
|
+
--quiet suppress notices
|
|
1337
|
+
--version print the version
|
|
1338
|
+
--help print this
|
|
1339
|
+
|
|
1340
|
+
ENVIRONMENT
|
|
1341
|
+
|
|
1342
|
+
PERSISTMEMORY_API_KEY a key, taking precedence over any stored
|
|
1343
|
+
login. This is what CI should set.
|
|
1344
|
+
PERSISTMEMORY_API_URL the server to talk to
|
|
1345
|
+
PERSISTMEMORY_PROFILE which stored profile to use
|
|
1346
|
+
PERSISTMEMORY_HOME where config, credentials and session
|
|
1347
|
+
transcripts live (default: ~/.persistmemory)
|
|
1348
|
+
PERSISTMEMORY_CLIENT_ID override the OAuth client id, for a
|
|
1349
|
+
self-hosted deployment
|
|
1350
|
+
|
|
1351
|
+
Docs: https://persistmemory.com/docs/cli
|
|
1352
|
+
`;
|
|
1353
|
+
|
|
1354
|
+
// src/auth/oauth.ts
|
|
1355
|
+
import { spawn } from "node:child_process";
|
|
1356
|
+
import { timingSafeEqual } from "node:crypto";
|
|
1357
|
+
|
|
1358
|
+
// src/auth/pkce.ts
|
|
1359
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
1360
|
+
function createPkce() {
|
|
1361
|
+
const verifier = base64Url(randomBytes(32));
|
|
1362
|
+
return {
|
|
1363
|
+
verifier,
|
|
1364
|
+
challenge: base64Url(createHash("sha256").update(verifier).digest()),
|
|
1365
|
+
// Never "plain". OAuth 2.1 removes it, and a plain challenge is the
|
|
1366
|
+
// verifier, which defends against nothing.
|
|
1367
|
+
method: "S256"
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
function randomState() {
|
|
1371
|
+
return base64Url(randomBytes(24));
|
|
1372
|
+
}
|
|
1373
|
+
function base64Url(bytes) {
|
|
1374
|
+
return bytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// src/auth/loopback.ts
|
|
1378
|
+
import { createServer } from "node:http";
|
|
1379
|
+
var CALLBACK_PATH = "/callback";
|
|
1380
|
+
async function startLoopback(options = {}) {
|
|
1381
|
+
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
|
|
1382
|
+
let resolveCallback;
|
|
1383
|
+
let rejectCallback;
|
|
1384
|
+
const received = new Promise((resolve5, reject) => {
|
|
1385
|
+
resolveCallback = resolve5;
|
|
1386
|
+
rejectCallback = reject;
|
|
1387
|
+
});
|
|
1388
|
+
const server = createServer((request, response) => {
|
|
1389
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
1390
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
1391
|
+
response.writeHead(404, { "content-type": "text/plain" });
|
|
1392
|
+
response.end("Not found");
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const callback = {
|
|
1396
|
+
...url.searchParams.get("code") ? { code: url.searchParams.get("code") } : {},
|
|
1397
|
+
...url.searchParams.get("state") ? { state: url.searchParams.get("state") } : {},
|
|
1398
|
+
...url.searchParams.get("error") ? { error: url.searchParams.get("error") } : {},
|
|
1399
|
+
...url.searchParams.get("error_description") ? { errorDescription: url.searchParams.get("error_description") } : {}
|
|
1400
|
+
};
|
|
1401
|
+
response.writeHead(200, {
|
|
1402
|
+
"content-type": "text/html; charset=utf-8",
|
|
1403
|
+
// This page is one-use and holds a result; nothing should keep it.
|
|
1404
|
+
"cache-control": "no-store",
|
|
1405
|
+
// It never loads anything, so it is not allowed to.
|
|
1406
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'"
|
|
1407
|
+
});
|
|
1408
|
+
response.end(donePage(callback));
|
|
1409
|
+
resolveCallback?.(callback);
|
|
1410
|
+
});
|
|
1411
|
+
await new Promise((resolve5, reject) => {
|
|
1412
|
+
server.once("error", reject);
|
|
1413
|
+
server.listen(0, "127.0.0.1", resolve5);
|
|
1414
|
+
});
|
|
1415
|
+
const address = server.address();
|
|
1416
|
+
if (address === null || typeof address === "string") {
|
|
1417
|
+
server.close();
|
|
1418
|
+
throw new Error("could not determine the port the callback listener bound to");
|
|
1419
|
+
}
|
|
1420
|
+
const timer = setTimeout(() => {
|
|
1421
|
+
rejectCallback?.(
|
|
1422
|
+
new Error("timed out waiting for the browser. Run `pm auth login` again, or use --api-key.")
|
|
1423
|
+
);
|
|
1424
|
+
}, timeoutMs);
|
|
1425
|
+
timer.unref?.();
|
|
1426
|
+
return {
|
|
1427
|
+
port: address.port,
|
|
1428
|
+
redirectUri: `http://127.0.0.1:${address.port}${CALLBACK_PATH}`,
|
|
1429
|
+
async waitForCallback() {
|
|
1430
|
+
try {
|
|
1431
|
+
return await received;
|
|
1432
|
+
} finally {
|
|
1433
|
+
clearTimeout(timer);
|
|
1434
|
+
}
|
|
1435
|
+
},
|
|
1436
|
+
close() {
|
|
1437
|
+
clearTimeout(timer);
|
|
1438
|
+
server.close();
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
function donePage(callback) {
|
|
1443
|
+
const failed = Boolean(callback.error) || !callback.code;
|
|
1444
|
+
const title = failed ? "Sign-in failed" : "You are signed in";
|
|
1445
|
+
const detail = failed ? escapeHtml(callback.errorDescription ?? callback.error ?? "No authorization code was returned.") : "You can close this tab and go back to your terminal.";
|
|
1446
|
+
return `<!doctype html>
|
|
1447
|
+
<html lang="en"><head><meta charset="utf-8"><title>${title}</title>
|
|
1448
|
+
<style>
|
|
1449
|
+
:root { color-scheme: light dark; }
|
|
1450
|
+
body { font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
1451
|
+
display: grid; place-items: center; min-height: 100vh; margin: 0; }
|
|
1452
|
+
main { max-width: 30rem; padding: 2rem; text-align: center; }
|
|
1453
|
+
h1 { font-size: 1.25rem; margin: 0 0 .5rem; }
|
|
1454
|
+
p { margin: 0; opacity: .8; }
|
|
1455
|
+
</style></head>
|
|
1456
|
+
<body><main><h1>${title}</h1><p>${detail}</p></main></body></html>`;
|
|
1457
|
+
}
|
|
1458
|
+
function escapeHtml(value) {
|
|
1459
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// src/auth/oauth.ts
|
|
1463
|
+
async function protectedResource(apiUrl, deps) {
|
|
1464
|
+
try {
|
|
1465
|
+
const url = new URL("/.well-known/oauth-protected-resource", apiUrl).toString();
|
|
1466
|
+
const response = await deps.fetch(url, { headers: { accept: "application/json" } });
|
|
1467
|
+
if (!response.ok) return void 0;
|
|
1468
|
+
const body = await response.json();
|
|
1469
|
+
const servers = body["authorization_servers"];
|
|
1470
|
+
const resource = body["resource"];
|
|
1471
|
+
if (!Array.isArray(servers) || typeof servers[0] !== "string") return void 0;
|
|
1472
|
+
if (typeof resource !== "string") return void 0;
|
|
1473
|
+
return { issuer: servers[0], resource };
|
|
1474
|
+
} catch {
|
|
1475
|
+
return void 0;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
async function discover(apiUrl, deps) {
|
|
1479
|
+
const guarded = await protectedResource(apiUrl, deps);
|
|
1480
|
+
const issuerUrl = guarded?.issuer ?? apiUrl;
|
|
1481
|
+
const url = new URL("/.well-known/oauth-authorization-server", issuerUrl).toString();
|
|
1482
|
+
const response = await deps.fetch(url, { headers: { accept: "application/json" } });
|
|
1483
|
+
if (!response.ok) {
|
|
1484
|
+
throw new Error(
|
|
1485
|
+
`${issuerUrl} does not look like a PersistMemory API: discovery answered ${response.status}.`
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
const body = await response.json();
|
|
1489
|
+
const required = (key) => {
|
|
1490
|
+
const value = body[key];
|
|
1491
|
+
if (typeof value !== "string") {
|
|
1492
|
+
throw new Error(`the server's metadata is missing "${key}"`);
|
|
1493
|
+
}
|
|
1494
|
+
return value;
|
|
1495
|
+
};
|
|
1496
|
+
return {
|
|
1497
|
+
issuer: required("issuer"),
|
|
1498
|
+
authorizationEndpoint: required("authorization_endpoint"),
|
|
1499
|
+
tokenEndpoint: required("token_endpoint"),
|
|
1500
|
+
// Carried through so the authorize request can name it. Absent when the
|
|
1501
|
+
// API is its own resource, in which case the server uses its default.
|
|
1502
|
+
...guarded?.resource ? { resource: guarded.resource } : {},
|
|
1503
|
+
...typeof body["registration_endpoint"] === "string" ? { registrationEndpoint: body["registration_endpoint"] } : {},
|
|
1504
|
+
...Array.isArray(body["scopes_supported"]) ? { scopesSupported: body["scopes_supported"].map(String) } : {}
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
async function registerClient(server, redirectUri, scope, deps) {
|
|
1508
|
+
if (!server.registrationEndpoint) {
|
|
1509
|
+
throw new Error(
|
|
1510
|
+
"this server does not offer dynamic client registration. Use `pm auth login --api-key` instead."
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
const response = await deps.fetch(server.registrationEndpoint, {
|
|
1514
|
+
method: "POST",
|
|
1515
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
1516
|
+
body: JSON.stringify({
|
|
1517
|
+
client_name: "PersistMemory CLI",
|
|
1518
|
+
client_uri: "https://persistmemory.com/docs/cli",
|
|
1519
|
+
// Registered WITHOUT a port. The server compares loopback redirects
|
|
1520
|
+
// ignoring the port (RFC 8252 §7.3), so registering the one we happened
|
|
1521
|
+
// to bind this time would be noise — and would break the next login,
|
|
1522
|
+
// which binds a different one.
|
|
1523
|
+
redirect_uris: ["http://127.0.0.1/callback"],
|
|
1524
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
1525
|
+
response_types: ["code"],
|
|
1526
|
+
token_endpoint_auth_method: "none",
|
|
1527
|
+
scope
|
|
1528
|
+
})
|
|
1529
|
+
});
|
|
1530
|
+
if (!response.ok) {
|
|
1531
|
+
throw new Error(`could not register with ${server.issuer}: ${await describe(response)}`);
|
|
1532
|
+
}
|
|
1533
|
+
const body = await response.json();
|
|
1534
|
+
if (typeof body.client_id !== "string") {
|
|
1535
|
+
throw new Error("the server registered this client but returned no client_id");
|
|
1536
|
+
}
|
|
1537
|
+
return body.client_id;
|
|
1538
|
+
}
|
|
1539
|
+
var CLI_CLIENT_ID = "persistmemory-cli";
|
|
1540
|
+
async function knownClient(server, clientId, deps) {
|
|
1541
|
+
try {
|
|
1542
|
+
const probe = new URL(server.authorizationEndpoint);
|
|
1543
|
+
probe.searchParams.set("client_id", clientId);
|
|
1544
|
+
probe.searchParams.set("response_type", "code");
|
|
1545
|
+
const response = await deps.fetch(probe.toString(), {
|
|
1546
|
+
method: "GET",
|
|
1547
|
+
redirect: "manual"
|
|
1548
|
+
});
|
|
1549
|
+
if (response.status >= 500) return false;
|
|
1550
|
+
const body = await response.text().catch(() => "");
|
|
1551
|
+
return !body.includes("invalid_client");
|
|
1552
|
+
} catch {
|
|
1553
|
+
return false;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
async function loginWithBrowser(args) {
|
|
1557
|
+
const { deps } = args;
|
|
1558
|
+
const print = deps.print ?? (() => void 0);
|
|
1559
|
+
const server = await discover(args.apiUrl, deps);
|
|
1560
|
+
const listener = await startLoopback(
|
|
1561
|
+
args.timeoutMs !== void 0 ? { timeoutMs: args.timeoutMs } : {}
|
|
1562
|
+
);
|
|
1563
|
+
try {
|
|
1564
|
+
const clientId = args.clientId ?? process.env["PERSISTMEMORY_CLIENT_ID"] ?? (await knownClient(server, CLI_CLIENT_ID, deps) ? CLI_CLIENT_ID : await registerClient(server, listener.redirectUri, args.scope, deps));
|
|
1565
|
+
const pkce = createPkce();
|
|
1566
|
+
const state = randomState();
|
|
1567
|
+
const authorize = new URL(server.authorizationEndpoint);
|
|
1568
|
+
authorize.searchParams.set("response_type", "code");
|
|
1569
|
+
authorize.searchParams.set("client_id", clientId);
|
|
1570
|
+
authorize.searchParams.set("redirect_uri", listener.redirectUri);
|
|
1571
|
+
authorize.searchParams.set("scope", args.scope);
|
|
1572
|
+
authorize.searchParams.set("state", state);
|
|
1573
|
+
authorize.searchParams.set("code_challenge", pkce.challenge);
|
|
1574
|
+
authorize.searchParams.set("code_challenge_method", pkce.method);
|
|
1575
|
+
if (server.resource) authorize.searchParams.set("resource", server.resource);
|
|
1576
|
+
print("Opening your browser to sign in.");
|
|
1577
|
+
print(`If it does not open, visit:
|
|
1578
|
+
|
|
1579
|
+
${authorize.toString()}
|
|
1580
|
+
`);
|
|
1581
|
+
await (deps.openBrowser ?? openBrowser)(authorize.toString()).catch(() => void 0);
|
|
1582
|
+
const callback = await listener.waitForCallback();
|
|
1583
|
+
if (callback.error) {
|
|
1584
|
+
throw new Error(
|
|
1585
|
+
`sign-in was refused: ${callback.errorDescription ?? callback.error}`
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
if (!callback.code) {
|
|
1589
|
+
throw new Error("the browser came back without an authorization code");
|
|
1590
|
+
}
|
|
1591
|
+
if (!callback.state || !safeEqual(callback.state, state)) {
|
|
1592
|
+
throw new Error("the browser came back with the wrong state \u2014 sign-in was not completed");
|
|
1593
|
+
}
|
|
1594
|
+
const credential = await redeem({
|
|
1595
|
+
server,
|
|
1596
|
+
clientId,
|
|
1597
|
+
code: callback.code,
|
|
1598
|
+
verifier: pkce.verifier,
|
|
1599
|
+
redirectUri: listener.redirectUri,
|
|
1600
|
+
deps
|
|
1601
|
+
});
|
|
1602
|
+
return { credential, clientId };
|
|
1603
|
+
} finally {
|
|
1604
|
+
listener.close();
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
async function redeem(args) {
|
|
1608
|
+
const form = new URLSearchParams({
|
|
1609
|
+
grant_type: "authorization_code",
|
|
1610
|
+
code: args.code,
|
|
1611
|
+
redirect_uri: args.redirectUri,
|
|
1612
|
+
client_id: args.clientId,
|
|
1613
|
+
code_verifier: args.verifier,
|
|
1614
|
+
// Restated at redemption. The server compares it against the resource the
|
|
1615
|
+
// code was authorized for and refuses a mismatch, which is what stops a
|
|
1616
|
+
// code issued for one service being redeemed for a token against another.
|
|
1617
|
+
...args.server.resource ? { resource: args.server.resource } : {}
|
|
1618
|
+
});
|
|
1619
|
+
const response = await args.deps.fetch(args.server.tokenEndpoint, {
|
|
1620
|
+
method: "POST",
|
|
1621
|
+
headers: {
|
|
1622
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1623
|
+
accept: "application/json"
|
|
1624
|
+
},
|
|
1625
|
+
body: form.toString()
|
|
1626
|
+
});
|
|
1627
|
+
if (!response.ok) {
|
|
1628
|
+
throw new Error(`the server refused to issue a token: ${await describe(response)}`);
|
|
1629
|
+
}
|
|
1630
|
+
return toCredential(await response.json());
|
|
1631
|
+
}
|
|
1632
|
+
async function refresh(args) {
|
|
1633
|
+
const server = await discover(args.apiUrl, args.deps);
|
|
1634
|
+
const response = await args.deps.fetch(server.tokenEndpoint, {
|
|
1635
|
+
method: "POST",
|
|
1636
|
+
headers: {
|
|
1637
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1638
|
+
accept: "application/json"
|
|
1639
|
+
},
|
|
1640
|
+
body: new URLSearchParams({
|
|
1641
|
+
grant_type: "refresh_token",
|
|
1642
|
+
refresh_token: args.refreshToken,
|
|
1643
|
+
client_id: args.clientId
|
|
1644
|
+
}).toString()
|
|
1645
|
+
});
|
|
1646
|
+
if (!response.ok) {
|
|
1647
|
+
throw new Error(`could not renew the session: ${await describe(response)}`);
|
|
1648
|
+
}
|
|
1649
|
+
const credential = toCredential(await response.json());
|
|
1650
|
+
return credential.refreshToken ? credential : { ...credential, refreshToken: args.refreshToken };
|
|
1651
|
+
}
|
|
1652
|
+
function toCredential(body) {
|
|
1653
|
+
const token = body["access_token"];
|
|
1654
|
+
if (typeof token !== "string") {
|
|
1655
|
+
throw new Error("the server's token response contained no access_token");
|
|
1656
|
+
}
|
|
1657
|
+
const expiresIn = body["expires_in"];
|
|
1658
|
+
const expiresAt = typeof expiresIn === "number" && Number.isFinite(expiresIn) ? new Date(Date.now() + expiresIn * 1e3).toISOString() : void 0;
|
|
1659
|
+
return {
|
|
1660
|
+
kind: "oauth",
|
|
1661
|
+
token,
|
|
1662
|
+
...typeof body["refresh_token"] === "string" ? { refreshToken: body["refresh_token"] } : {},
|
|
1663
|
+
...expiresAt ? { expiresAt } : {},
|
|
1664
|
+
...typeof body["scope"] === "string" ? { scope: body["scope"] } : {}
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
function safeEqual(a, b) {
|
|
1668
|
+
const left = Buffer.from(a);
|
|
1669
|
+
const right = Buffer.from(b);
|
|
1670
|
+
if (left.length !== right.length) return false;
|
|
1671
|
+
return timingSafeEqual(left, right);
|
|
1672
|
+
}
|
|
1673
|
+
async function openBrowser(url) {
|
|
1674
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
1675
|
+
await new Promise((resolve5, reject) => {
|
|
1676
|
+
const child = spawn(command, args, {
|
|
1677
|
+
stdio: "ignore",
|
|
1678
|
+
// Detached so closing the terminal does not close the browser, and so
|
|
1679
|
+
// this process can exit without waiting for it.
|
|
1680
|
+
detached: true
|
|
1681
|
+
});
|
|
1682
|
+
child.once("error", reject);
|
|
1683
|
+
child.unref();
|
|
1684
|
+
resolve5();
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
async function describe(response) {
|
|
1688
|
+
try {
|
|
1689
|
+
const body = await response.json();
|
|
1690
|
+
const error = body["error"];
|
|
1691
|
+
const description = body["error_description"];
|
|
1692
|
+
if (typeof description === "string") return `${String(error ?? response.status)} \u2014 ${description}`;
|
|
1693
|
+
if (typeof error === "string") return error;
|
|
1694
|
+
} catch {
|
|
1695
|
+
}
|
|
1696
|
+
return `HTTP ${response.status}`;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// src/session.ts
|
|
1700
|
+
var NotSignedIn = class extends Error {
|
|
1701
|
+
constructor() {
|
|
1702
|
+
super(
|
|
1703
|
+
"not signed in. Run `pm auth login`, or set PERSISTMEMORY_API_KEY for a non-interactive session."
|
|
1704
|
+
);
|
|
1705
|
+
this.name = "NotSignedIn";
|
|
1706
|
+
}
|
|
1707
|
+
};
|
|
1708
|
+
var RENEW_BEFORE_MS = 6e4;
|
|
1709
|
+
function isExpired(credential, now = Date.now()) {
|
|
1710
|
+
if (credential.kind !== "oauth" || !credential.expiresAt) return false;
|
|
1711
|
+
const at = Date.parse(credential.expiresAt);
|
|
1712
|
+
return Number.isFinite(at) && at - RENEW_BEFORE_MS <= now;
|
|
1713
|
+
}
|
|
1714
|
+
async function clientFor(resolved, deps) {
|
|
1715
|
+
const credential = await currentCredential(resolved, deps);
|
|
1716
|
+
return new PersistMemory({
|
|
1717
|
+
apiKey: credential.token,
|
|
1718
|
+
baseUrl: resolved.apiUrl,
|
|
1719
|
+
...deps.userAgent ? { userAgent: deps.userAgent } : {},
|
|
1720
|
+
fetch: deps.fetch
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
async function currentCredential(resolved, deps) {
|
|
1724
|
+
const credential = resolved.credential;
|
|
1725
|
+
if (!credential) throw new NotSignedIn();
|
|
1726
|
+
if (!isExpired(credential, deps.now?.() ?? Date.now())) return credential;
|
|
1727
|
+
if (!credential.refreshToken || !resolved.clientId) {
|
|
1728
|
+
throw new Error("your session has expired. Run `pm auth login` to sign in again.");
|
|
1729
|
+
}
|
|
1730
|
+
const renewed = await refresh({
|
|
1731
|
+
apiUrl: resolved.apiUrl,
|
|
1732
|
+
clientId: resolved.clientId,
|
|
1733
|
+
refreshToken: credential.refreshToken,
|
|
1734
|
+
deps
|
|
1735
|
+
});
|
|
1736
|
+
writeCredentials(deps.paths, {
|
|
1737
|
+
...readCredentials(deps.paths),
|
|
1738
|
+
[resolved.profile]: renewed
|
|
1739
|
+
});
|
|
1740
|
+
return renewed;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// src/context.ts
|
|
1744
|
+
import { createInterface } from "node:readline";
|
|
1745
|
+
var ETX = "";
|
|
1746
|
+
var DELETE = "\x7F";
|
|
1747
|
+
var BACKSPACE = "\b";
|
|
1748
|
+
async function readSecretFromTty(prompt) {
|
|
1749
|
+
const input = process.stdin;
|
|
1750
|
+
if (!input.isTTY) {
|
|
1751
|
+
return new Promise((resolve5) => {
|
|
1752
|
+
const readline = createInterface({ input });
|
|
1753
|
+
readline.once("line", (line) => {
|
|
1754
|
+
readline.close();
|
|
1755
|
+
resolve5(line.trim());
|
|
1756
|
+
});
|
|
1757
|
+
readline.once("close", () => resolve5(""));
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
process.stdout.write(prompt);
|
|
1761
|
+
const previouslyRaw = input.isRaw ?? false;
|
|
1762
|
+
input.setRawMode?.(true);
|
|
1763
|
+
input.resume();
|
|
1764
|
+
input.setEncoding("utf8");
|
|
1765
|
+
return new Promise((resolve5) => {
|
|
1766
|
+
let value = "";
|
|
1767
|
+
const finish = () => {
|
|
1768
|
+
input.removeListener("data", onData);
|
|
1769
|
+
input.setRawMode?.(previouslyRaw);
|
|
1770
|
+
input.pause();
|
|
1771
|
+
process.stdout.write("\n");
|
|
1772
|
+
resolve5(value.trim());
|
|
1773
|
+
};
|
|
1774
|
+
const onData = (chunk) => {
|
|
1775
|
+
for (const character of chunk) {
|
|
1776
|
+
switch (character) {
|
|
1777
|
+
case "\r":
|
|
1778
|
+
case "\n":
|
|
1779
|
+
finish();
|
|
1780
|
+
return;
|
|
1781
|
+
case ETX:
|
|
1782
|
+
input.setRawMode?.(previouslyRaw);
|
|
1783
|
+
process.stdout.write("\n");
|
|
1784
|
+
process.exit(130);
|
|
1785
|
+
return;
|
|
1786
|
+
case DELETE:
|
|
1787
|
+
case BACKSPACE:
|
|
1788
|
+
value = value.slice(0, -1);
|
|
1789
|
+
break;
|
|
1790
|
+
default:
|
|
1791
|
+
if (character >= " ") value += character;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
};
|
|
1795
|
+
input.on("data", onData);
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
async function readStdin() {
|
|
1799
|
+
const chunks = [];
|
|
1800
|
+
for await (const chunk of process.stdin) {
|
|
1801
|
+
chunks.push(Buffer.from(chunk));
|
|
1802
|
+
}
|
|
1803
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// src/commands/agent.ts
|
|
1807
|
+
import { hostname } from "node:os";
|
|
1808
|
+
import { homedir as homedir2 } from "node:os";
|
|
1809
|
+
import { resolve as resolve3 } from "node:path";
|
|
1810
|
+
import { readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
|
|
1811
|
+
|
|
1812
|
+
// src/files.ts
|
|
1813
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1814
|
+
import { dirname, isAbsolute, join as join2, relative, resolve as resolve2 } from "node:path";
|
|
1815
|
+
var OutsideWorkspace = class extends Error {
|
|
1816
|
+
constructor(path) {
|
|
1817
|
+
super(`${path} is outside the directory this session was started in.`);
|
|
1818
|
+
this.name = "OutsideWorkspace";
|
|
1819
|
+
}
|
|
1820
|
+
};
|
|
1821
|
+
var TooLarge = class extends Error {
|
|
1822
|
+
constructor(path, bytes, limit) {
|
|
1823
|
+
super(`${path} is ${Math.round(bytes / 1024)} KB, over the ${Math.round(limit / 1024)} KB limit.`);
|
|
1824
|
+
this.name = "TooLarge";
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
var MAX_READ_BYTES = 512 * 1024;
|
|
1828
|
+
function realLocation(absolute) {
|
|
1829
|
+
let existing = absolute;
|
|
1830
|
+
const trailing = [];
|
|
1831
|
+
while (!existsSync2(existing)) {
|
|
1832
|
+
const parent = dirname(existing);
|
|
1833
|
+
if (parent === existing) return absolute;
|
|
1834
|
+
trailing.unshift(existing.slice(parent.length + 1));
|
|
1835
|
+
existing = parent;
|
|
1836
|
+
}
|
|
1837
|
+
try {
|
|
1838
|
+
return join2(realpathSync(existing), ...trailing);
|
|
1839
|
+
} catch {
|
|
1840
|
+
return absolute;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
function within(root, path) {
|
|
1844
|
+
const absolute = isAbsolute(path) ? path : resolve2(root, path);
|
|
1845
|
+
const real = realLocation(absolute);
|
|
1846
|
+
const realRoot = realLocation(resolve2(root));
|
|
1847
|
+
const rel = relative(realRoot, real);
|
|
1848
|
+
if (rel !== "" && (rel.startsWith("..") || isAbsolute(rel))) {
|
|
1849
|
+
throw new OutsideWorkspace(path);
|
|
1850
|
+
}
|
|
1851
|
+
return real;
|
|
1852
|
+
}
|
|
1853
|
+
function readWithin(root, path) {
|
|
1854
|
+
const absolute = within(root, path);
|
|
1855
|
+
const stats = statSync(absolute);
|
|
1856
|
+
if (!stats.isFile()) throw new Error(`${path} is not a file.`);
|
|
1857
|
+
if (stats.size > MAX_READ_BYTES) throw new TooLarge(path, stats.size, MAX_READ_BYTES);
|
|
1858
|
+
return {
|
|
1859
|
+
path: absolute,
|
|
1860
|
+
text: readFileSync2(absolute, "utf8"),
|
|
1861
|
+
bytes: stats.size
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
function proposeWrite(root, path, contents) {
|
|
1865
|
+
const absolute = within(root, path);
|
|
1866
|
+
let existing;
|
|
1867
|
+
try {
|
|
1868
|
+
const stats = statSync(absolute);
|
|
1869
|
+
if (stats.isFile() && stats.size <= MAX_READ_BYTES) {
|
|
1870
|
+
existing = readFileSync2(absolute, "utf8");
|
|
1871
|
+
}
|
|
1872
|
+
} catch {
|
|
1873
|
+
}
|
|
1874
|
+
return {
|
|
1875
|
+
path: absolute,
|
|
1876
|
+
contents,
|
|
1877
|
+
...existing !== void 0 ? { existing } : {}
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
function commitWrite(write2, confirmed) {
|
|
1881
|
+
if (!confirmed) throw new Error("refusing to write without confirmation");
|
|
1882
|
+
writeFileSync2(write2.path, write2.contents, "utf8");
|
|
1883
|
+
}
|
|
1884
|
+
function summarise(write2, maxLines = 40) {
|
|
1885
|
+
if (write2.existing === void 0) {
|
|
1886
|
+
const lines = write2.contents.split("\n");
|
|
1887
|
+
const head = lines.slice(0, maxLines).map((line) => `+ ${line}`);
|
|
1888
|
+
if (lines.length > maxLines) head.push(` \u2026 ${lines.length - maxLines} more lines`);
|
|
1889
|
+
return `create ${write2.path} (${lines.length} lines)
|
|
1890
|
+
${head.join("\n")}`;
|
|
1891
|
+
}
|
|
1892
|
+
if (write2.existing === write2.contents) return `${write2.path} is already exactly this.`;
|
|
1893
|
+
const before = write2.existing.split("\n");
|
|
1894
|
+
const after = write2.contents.split("\n");
|
|
1895
|
+
const changes = [];
|
|
1896
|
+
let start = 0;
|
|
1897
|
+
while (start < before.length && start < after.length && before[start] === after[start]) {
|
|
1898
|
+
start += 1;
|
|
1899
|
+
}
|
|
1900
|
+
let end = 0;
|
|
1901
|
+
while (end < before.length - start && end < after.length - start && before[before.length - 1 - end] === after[after.length - 1 - end]) {
|
|
1902
|
+
end += 1;
|
|
1903
|
+
}
|
|
1904
|
+
const removed = before.slice(start, before.length - end);
|
|
1905
|
+
const added = after.slice(start, after.length - end);
|
|
1906
|
+
for (const line of removed.slice(0, maxLines)) changes.push(`- ${line}`);
|
|
1907
|
+
if (removed.length > maxLines) changes.push(` \u2026 ${removed.length - maxLines} more removed`);
|
|
1908
|
+
for (const line of added.slice(0, maxLines)) changes.push(`+ ${line}`);
|
|
1909
|
+
if (added.length > maxLines) changes.push(` \u2026 ${added.length - maxLines} more added`);
|
|
1910
|
+
return [
|
|
1911
|
+
`edit ${write2.path} (line ${start + 1}: -${removed.length} +${added.length})`,
|
|
1912
|
+
...changes
|
|
1913
|
+
].join("\n");
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
// src/commands/agent.ts
|
|
1917
|
+
async function said(response, fallback) {
|
|
1918
|
+
const body = await response.json().catch(() => void 0);
|
|
1919
|
+
const message2 = typeof body === "object" && body !== null && "error" in body ? body.error?.message : void 0;
|
|
1920
|
+
return message2 ?? `${fallback} (${response.status})`;
|
|
1921
|
+
}
|
|
1922
|
+
function locate(roots, requested) {
|
|
1923
|
+
const expanded = requested.startsWith("~") ? resolve3(homedir2(), requested.slice(1).replace(/^[/\\]/, "")) : requested;
|
|
1924
|
+
for (const root of roots) {
|
|
1925
|
+
try {
|
|
1926
|
+
return within(root, expanded);
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
if (!(error instanceof OutsideWorkspace)) throw error;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
throw new Error(`${requested} is not inside any allowed folder (${roots.join(", ")})`);
|
|
1932
|
+
}
|
|
1933
|
+
async function answer(context, apiUrl, token, roots, request) {
|
|
1934
|
+
let located;
|
|
1935
|
+
try {
|
|
1936
|
+
located = locate(roots, request.path);
|
|
1937
|
+
} catch (error) {
|
|
1938
|
+
return { ok: false, error: error instanceof Error ? error.message : "refused" };
|
|
1939
|
+
}
|
|
1940
|
+
let bytes;
|
|
1941
|
+
try {
|
|
1942
|
+
const stats = statSync2(located);
|
|
1943
|
+
if (!stats.isFile()) return { ok: false, error: `${request.path} is not a file.` };
|
|
1944
|
+
if (stats.size > MAX_READ_BYTES) {
|
|
1945
|
+
return {
|
|
1946
|
+
ok: false,
|
|
1947
|
+
error: new TooLarge(request.path, stats.size, MAX_READ_BYTES).message
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
bytes = readFileSync3(located);
|
|
1951
|
+
} catch (error) {
|
|
1952
|
+
return { ok: false, error: error instanceof Error ? error.message : "could not read it" };
|
|
1953
|
+
}
|
|
1954
|
+
const grant = await fetch(`${apiUrl}/api/v1/agent/upload-url`, {
|
|
1955
|
+
method: "POST",
|
|
1956
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
1957
|
+
body: JSON.stringify({
|
|
1958
|
+
// The name the person asked for, not the resolved path. The resolved one
|
|
1959
|
+
// says where this machine keeps things, which the server has no business
|
|
1960
|
+
// recording.
|
|
1961
|
+
//
|
|
1962
|
+
// No content type is sent: this machine has a path, not a declaration.
|
|
1963
|
+
// The server resolves it from the name against the one table that knows
|
|
1964
|
+
// which types it can read, and tells us below what it decided.
|
|
1965
|
+
filename: request.path.split("/").pop() ?? "file"
|
|
1966
|
+
})
|
|
1967
|
+
});
|
|
1968
|
+
if (!grant.ok) {
|
|
1969
|
+
return { ok: false, error: await said(grant, "could not get an upload url") };
|
|
1970
|
+
}
|
|
1971
|
+
const { uploadUrl, contentType } = await grant.json();
|
|
1972
|
+
const put = await fetch(uploadUrl, {
|
|
1973
|
+
method: "PUT",
|
|
1974
|
+
// The type the grant was signed for. Anything else is refused.
|
|
1975
|
+
headers: { "content-type": contentType },
|
|
1976
|
+
body: new Uint8Array(bytes)
|
|
1977
|
+
});
|
|
1978
|
+
if (!put.ok) return { ok: false, error: `upload refused (${put.status})` };
|
|
1979
|
+
const stored = await put.json().catch(() => ({}));
|
|
1980
|
+
if (!stored.attachToken) return { ok: false, error: "the upload returned no reference" };
|
|
1981
|
+
return { ok: true, attachToken: stored.attachToken, bytes: bytes.length };
|
|
1982
|
+
}
|
|
1983
|
+
async function agentCommand(context) {
|
|
1984
|
+
const credential = context.resolved.credential;
|
|
1985
|
+
if (!credential) {
|
|
1986
|
+
context.error("Sign in first: pm auth login");
|
|
1987
|
+
return 1;
|
|
1988
|
+
}
|
|
1989
|
+
const roots = (listFlag(context.args, "root") ?? []).map(
|
|
1990
|
+
(one) => resolve3(one.startsWith("~") ? resolve3(homedir2(), one.slice(1).replace(/^[/\\]/, "")) : one)
|
|
1991
|
+
);
|
|
1992
|
+
if (roots.length === 0) {
|
|
1993
|
+
context.error(
|
|
1994
|
+
"Say which folders this machine may read from, and nothing outside them will be:"
|
|
1995
|
+
);
|
|
1996
|
+
context.error(" pm agent --root ~/notes --root ~/projects");
|
|
1997
|
+
return 1;
|
|
1998
|
+
}
|
|
1999
|
+
for (const root of roots) {
|
|
2000
|
+
try {
|
|
2001
|
+
if (!statSync2(root).isDirectory()) {
|
|
2002
|
+
context.error(`${root} is not a folder.`);
|
|
2003
|
+
return 1;
|
|
2004
|
+
}
|
|
2005
|
+
} catch {
|
|
2006
|
+
context.error(`${root} does not exist.`);
|
|
2007
|
+
return 1;
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
const apiUrl = context.resolved.apiUrl.replace(/\/+$/, "");
|
|
2011
|
+
const name = hostname();
|
|
2012
|
+
const asked = numberFlag(context.args, "interval");
|
|
2013
|
+
if (asked === "invalid") {
|
|
2014
|
+
context.error("--interval takes a number of seconds.");
|
|
2015
|
+
return 1;
|
|
2016
|
+
}
|
|
2017
|
+
const every = Math.max(2, asked ?? 5) * 1e3;
|
|
2018
|
+
context.print(`Answering as ${name}, from: ${roots.join(", ")}`);
|
|
2019
|
+
context.print("Nothing outside those folders can be read. Ctrl-C to stop.");
|
|
2020
|
+
let running = true;
|
|
2021
|
+
const stop = () => {
|
|
2022
|
+
running = false;
|
|
2023
|
+
context.print("\nStopping. The current request will finish first.");
|
|
2024
|
+
};
|
|
2025
|
+
process.on("SIGINT", stop);
|
|
2026
|
+
process.on("SIGTERM", stop);
|
|
2027
|
+
const call = async (path, body) => fetch(`${apiUrl}/api/v1/agent/${path}`, {
|
|
2028
|
+
method: "POST",
|
|
2029
|
+
headers: {
|
|
2030
|
+
authorization: `Bearer ${credential.token}`,
|
|
2031
|
+
"content-type": "application/json"
|
|
2032
|
+
},
|
|
2033
|
+
body: JSON.stringify(body)
|
|
2034
|
+
});
|
|
2035
|
+
let complaint;
|
|
2036
|
+
const complain = (message2) => {
|
|
2037
|
+
if (complaint === message2) return;
|
|
2038
|
+
complaint = message2;
|
|
2039
|
+
context.error(` ${message2}`);
|
|
2040
|
+
};
|
|
2041
|
+
const working = () => {
|
|
2042
|
+
if (complaint !== void 0) {
|
|
2043
|
+
complaint = void 0;
|
|
2044
|
+
context.print(" connected again");
|
|
2045
|
+
}
|
|
2046
|
+
};
|
|
2047
|
+
while (running) {
|
|
2048
|
+
try {
|
|
2049
|
+
const beat = await call("heartbeat", { hostname: name, platform: process.platform });
|
|
2050
|
+
if (!beat.ok) {
|
|
2051
|
+
complain(await said(beat, "the service refused this machine"));
|
|
2052
|
+
} else {
|
|
2053
|
+
working();
|
|
2054
|
+
const state = await beat.json();
|
|
2055
|
+
if (state.status === "disabled") {
|
|
2056
|
+
context.print("This machine is switched off after going quiet. Ask an operator to re-enable it.");
|
|
2057
|
+
await new Promise((r) => setTimeout(r, 6e4));
|
|
2058
|
+
continue;
|
|
2059
|
+
}
|
|
2060
|
+
if (state.released > 0) {
|
|
2061
|
+
context.print(`Reconnected. ${state.released} request(s) were waiting.`);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
const claimed = await call("claim", { hostname: name, limit: 5 });
|
|
2065
|
+
if (!claimed.ok) {
|
|
2066
|
+
complain(await said(claimed, "could not pick up work"));
|
|
2067
|
+
} else {
|
|
2068
|
+
const { items } = await claimed.json();
|
|
2069
|
+
for (const request of items) {
|
|
2070
|
+
context.print(`Reading ${request.path}`);
|
|
2071
|
+
const outcome = await answer(context, apiUrl, credential.token, roots, request);
|
|
2072
|
+
const done = await call(
|
|
2073
|
+
`complete/${encodeURIComponent(request.id)}`,
|
|
2074
|
+
outcome.ok ? { result: { attachToken: outcome.attachToken } } : { error: outcome.error }
|
|
2075
|
+
);
|
|
2076
|
+
if (!done.ok) {
|
|
2077
|
+
context.error(` stored, but the service did not record it: ${await said(done, "refused")}`);
|
|
2078
|
+
continue;
|
|
2079
|
+
}
|
|
2080
|
+
context.print(outcome.ok ? ` sent ${outcome.bytes} bytes` : ` refused: ${outcome.error}`);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
} catch (error) {
|
|
2084
|
+
context.error(` ${error instanceof Error ? error.message : "connection failed"}`);
|
|
2085
|
+
}
|
|
2086
|
+
if (running) await new Promise((r) => setTimeout(r, every));
|
|
2087
|
+
}
|
|
2088
|
+
return 0;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// src/commands/requests.ts
|
|
2092
|
+
async function requestsCommand(context) {
|
|
2093
|
+
const credential = context.resolved.credential;
|
|
2094
|
+
if (!credential) {
|
|
2095
|
+
context.error("Sign in first: pm auth login");
|
|
2096
|
+
return 1;
|
|
2097
|
+
}
|
|
2098
|
+
const apiUrl = context.resolved.apiUrl.replace(/\/+$/, "");
|
|
2099
|
+
const response = await fetch(`${apiUrl}/api/v1/agent/awaiting`, {
|
|
2100
|
+
headers: { authorization: `Bearer ${credential.token}` }
|
|
2101
|
+
});
|
|
2102
|
+
if (response.status === 403) {
|
|
2103
|
+
context.error(
|
|
2104
|
+
"This credential cannot see the approval queue. It is visible only to you, signed in, at /dashboard/requests."
|
|
2105
|
+
);
|
|
2106
|
+
return 3;
|
|
2107
|
+
}
|
|
2108
|
+
if (!response.ok) {
|
|
2109
|
+
context.error(`Could not read the queue (${response.status}).`);
|
|
2110
|
+
return 1;
|
|
2111
|
+
}
|
|
2112
|
+
const { items } = await response.json();
|
|
2113
|
+
if (items.length === 0) {
|
|
2114
|
+
context.print("Nothing is waiting. No app or chat has asked for a file from your computers.");
|
|
2115
|
+
return 0;
|
|
2116
|
+
}
|
|
2117
|
+
context.print(
|
|
2118
|
+
`${items.length} request${items.length === 1 ? "" : "s"} waiting. Nothing has been read.`
|
|
2119
|
+
);
|
|
2120
|
+
context.print("");
|
|
2121
|
+
for (const one of items) {
|
|
2122
|
+
context.print(` ${one.path}`);
|
|
2123
|
+
context.print(` asked by ${one.askedBy ?? "something"} \xB7 ${one.id}`);
|
|
2124
|
+
context.print("");
|
|
2125
|
+
}
|
|
2126
|
+
context.print("Approve or refuse them while signed in, at /dashboard/requests.");
|
|
2127
|
+
context.print("They cannot be approved from here \u2014 see `pm help requests`.");
|
|
2128
|
+
return 0;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// src/commands/auth.ts
|
|
2132
|
+
async function authCommand(context) {
|
|
2133
|
+
const action = context.args.words[1] ?? "status";
|
|
2134
|
+
switch (action) {
|
|
2135
|
+
case "login":
|
|
2136
|
+
return login(context);
|
|
2137
|
+
case "logout":
|
|
2138
|
+
return logout(context);
|
|
2139
|
+
case "status":
|
|
2140
|
+
return status(context);
|
|
2141
|
+
default:
|
|
2142
|
+
context.error(`Unknown command "pm auth ${action}". Try login, logout or status.`);
|
|
2143
|
+
return 2;
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
var SCOPE = "memory:read memory:capture memory:write usage:read";
|
|
2147
|
+
async function login(context) {
|
|
2148
|
+
const { flags } = context;
|
|
2149
|
+
const profile = flags.profile ?? DEFAULT_PROFILE;
|
|
2150
|
+
const apiUrl = context.resolved.apiUrl;
|
|
2151
|
+
if (flags.apiKey !== void 0) {
|
|
2152
|
+
const key = flags.apiKey === true ? await context.readSecret("API key: ") : flags.apiKey;
|
|
2153
|
+
if (!key) {
|
|
2154
|
+
context.error("No API key was given.");
|
|
2155
|
+
return 2;
|
|
2156
|
+
}
|
|
2157
|
+
saveLogin({
|
|
2158
|
+
paths: context.paths,
|
|
2159
|
+
profile,
|
|
2160
|
+
apiUrl,
|
|
2161
|
+
credential: { kind: "api-key", token: key }
|
|
2162
|
+
});
|
|
2163
|
+
context.print(`Signed in to ${apiUrl} as profile "${profile}" with an API key.`);
|
|
2164
|
+
return 0;
|
|
2165
|
+
}
|
|
2166
|
+
try {
|
|
2167
|
+
const { credential, clientId } = await loginWithBrowser({
|
|
2168
|
+
apiUrl,
|
|
2169
|
+
scope: SCOPE,
|
|
2170
|
+
clientId: context.resolved.clientId,
|
|
2171
|
+
deps: context.oauth
|
|
2172
|
+
});
|
|
2173
|
+
saveLogin({
|
|
2174
|
+
paths: context.paths,
|
|
2175
|
+
profile,
|
|
2176
|
+
apiUrl,
|
|
2177
|
+
credential,
|
|
2178
|
+
clientId
|
|
2179
|
+
});
|
|
2180
|
+
context.print(`
|
|
2181
|
+
Signed in to ${apiUrl} as profile "${profile}".`);
|
|
2182
|
+
return 0;
|
|
2183
|
+
} catch (error) {
|
|
2184
|
+
context.error(message(error));
|
|
2185
|
+
return 1;
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
function logout(context) {
|
|
2189
|
+
const profile = context.flags.profile ?? context.resolved.profile;
|
|
2190
|
+
const forgotten = clearLogin(context.paths, profile);
|
|
2191
|
+
context.print(
|
|
2192
|
+
forgotten ? `Signed out of profile "${profile}".` : `Profile "${profile}" was not signed in.`
|
|
2193
|
+
);
|
|
2194
|
+
if (process.env["PERSISTMEMORY_API_KEY"]) {
|
|
2195
|
+
context.print(
|
|
2196
|
+
"PERSISTMEMORY_API_KEY is still set in this shell, and takes precedence. Unset it to finish signing out."
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
return 0;
|
|
2200
|
+
}
|
|
2201
|
+
async function status(context) {
|
|
2202
|
+
const config = readConfig(context.paths);
|
|
2203
|
+
const resolved = context.resolved;
|
|
2204
|
+
if (!resolved.credential) {
|
|
2205
|
+
context.print(`Not signed in. Run \`pm auth login\`.
|
|
2206
|
+
|
|
2207
|
+
api url ${resolved.apiUrl}`);
|
|
2208
|
+
return 1;
|
|
2209
|
+
}
|
|
2210
|
+
const lines = [
|
|
2211
|
+
` profile ${resolved.profile}${config.current === resolved.profile ? " (current)" : ""}`,
|
|
2212
|
+
` api url ${resolved.apiUrl}`,
|
|
2213
|
+
` method ${resolved.credential.kind === "api-key" ? "API key" : "browser sign-in"}`,
|
|
2214
|
+
` token ${maskToken(resolved.credential.token)}`
|
|
2215
|
+
];
|
|
2216
|
+
if (resolved.fromEnvironment) {
|
|
2217
|
+
lines.push(" source PERSISTMEMORY_API_KEY (overrides the stored profile)");
|
|
2218
|
+
}
|
|
2219
|
+
if (resolved.credential.expiresAt) {
|
|
2220
|
+
lines.push(` expires ${resolved.credential.expiresAt}`);
|
|
2221
|
+
}
|
|
2222
|
+
if (resolved.credential.scope) {
|
|
2223
|
+
lines.push(` scope ${resolved.credential.scope}`);
|
|
2224
|
+
}
|
|
2225
|
+
context.print(lines.join("\n"));
|
|
2226
|
+
try {
|
|
2227
|
+
await currentCredential(resolved, context.session);
|
|
2228
|
+
const client = await context.client();
|
|
2229
|
+
await client.health.ready();
|
|
2230
|
+
context.print("\n The server accepted this credential.");
|
|
2231
|
+
return 0;
|
|
2232
|
+
} catch (error) {
|
|
2233
|
+
context.print(`
|
|
2234
|
+
The server did NOT accept this credential: ${message(error)}`);
|
|
2235
|
+
return 1;
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
function message(error) {
|
|
2239
|
+
return error instanceof Error ? error.message : String(error);
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// src/commands/session.ts
|
|
2243
|
+
import { createInterface as createInterface2 } from "node:readline";
|
|
2244
|
+
import { randomUUID } from "node:crypto";
|
|
2245
|
+
import { relative as relative2 } from "node:path";
|
|
2246
|
+
|
|
2247
|
+
// src/events.ts
|
|
2248
|
+
import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync4 } from "node:fs";
|
|
2249
|
+
import { join as join3 } from "node:path";
|
|
2250
|
+
function openSessionLog(paths, id) {
|
|
2251
|
+
const directory = join3(paths.dir, "sessions");
|
|
2252
|
+
mkdirSync2(directory, { recursive: true, mode: 448 });
|
|
2253
|
+
const path = join3(directory, `${id}.jsonl`);
|
|
2254
|
+
return {
|
|
2255
|
+
id,
|
|
2256
|
+
path,
|
|
2257
|
+
append(event) {
|
|
2258
|
+
try {
|
|
2259
|
+
appendFileSync(path, `${JSON.stringify(event)}
|
|
2260
|
+
`, { mode: 384 });
|
|
2261
|
+
} catch {
|
|
2262
|
+
}
|
|
2263
|
+
},
|
|
2264
|
+
read() {
|
|
2265
|
+
if (!existsSync3(path)) return [];
|
|
2266
|
+
return readFileSync4(path, "utf8").split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
|
|
2267
|
+
try {
|
|
2268
|
+
return [JSON.parse(line)];
|
|
2269
|
+
} catch {
|
|
2270
|
+
return [];
|
|
2271
|
+
}
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
function turnsFrom(events) {
|
|
2277
|
+
const turns = [];
|
|
2278
|
+
for (const event of events) {
|
|
2279
|
+
if (event.kind === "prompt") turns.push({ role: "user", content: event.text });
|
|
2280
|
+
if (event.kind === "reply") turns.push({ role: "assistant", content: event.text });
|
|
2281
|
+
}
|
|
2282
|
+
return turns;
|
|
2283
|
+
}
|
|
2284
|
+
function totalUsage(events) {
|
|
2285
|
+
let turns = 0;
|
|
2286
|
+
let inputTokens = 0;
|
|
2287
|
+
let outputTokens = 0;
|
|
2288
|
+
for (const event of events) {
|
|
2289
|
+
if (event.kind !== "reply") continue;
|
|
2290
|
+
turns += 1;
|
|
2291
|
+
inputTokens += event.usage?.inputTokens ?? 0;
|
|
2292
|
+
outputTokens += event.usage?.outputTokens ?? 0;
|
|
2293
|
+
}
|
|
2294
|
+
return { turns, inputTokens, outputTokens };
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// src/commands/session.ts
|
|
2298
|
+
var HELP2 = `
|
|
2299
|
+
Commands
|
|
2300
|
+
|
|
2301
|
+
/read <path> read a file into the conversation
|
|
2302
|
+
/capture <path> read a file AND remember it
|
|
2303
|
+
/write <path> write the last reply to a file (asks first)
|
|
2304
|
+
/remember <text> remember something directly
|
|
2305
|
+
/usage what this session has cost
|
|
2306
|
+
/new start a fresh conversation
|
|
2307
|
+
/exit leave (Ctrl-D also works)
|
|
2308
|
+
|
|
2309
|
+
Anything else is a question, answered from your memory.
|
|
2310
|
+
`;
|
|
2311
|
+
async function sessionCommand(context) {
|
|
2312
|
+
const resumeId = stringFlag(context.args, "resume");
|
|
2313
|
+
const id = resumeId ?? randomUUID();
|
|
2314
|
+
const log = openSessionLog(context.paths, id);
|
|
2315
|
+
const state = { turns: [], attached: [] };
|
|
2316
|
+
if (resumeId) {
|
|
2317
|
+
const previous = log.read();
|
|
2318
|
+
state.turns = turnsFrom(previous);
|
|
2319
|
+
if (state.turns.length === 0) {
|
|
2320
|
+
context.error(`No session "${resumeId}" to resume.`);
|
|
2321
|
+
return 1;
|
|
2322
|
+
}
|
|
2323
|
+
context.print(`Resumed session ${resumeId} \u2014 ${state.turns.length} turns.`);
|
|
2324
|
+
}
|
|
2325
|
+
try {
|
|
2326
|
+
await context.client();
|
|
2327
|
+
} catch (error) {
|
|
2328
|
+
context.error(error instanceof Error ? error.message : String(error));
|
|
2329
|
+
return 3;
|
|
2330
|
+
}
|
|
2331
|
+
log.append({
|
|
2332
|
+
kind: "session.started",
|
|
2333
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2334
|
+
cwd: process.cwd(),
|
|
2335
|
+
apiUrl: context.resolved.apiUrl,
|
|
2336
|
+
profile: context.resolved.profile
|
|
2337
|
+
});
|
|
2338
|
+
context.print(`
|
|
2339
|
+
PersistMemory \u2014 session ${id.slice(0, 8)}`);
|
|
2340
|
+
context.print(` Ask anything. /help for commands, /exit to leave.
|
|
2341
|
+
`);
|
|
2342
|
+
const readline = createInterface2({ input: process.stdin, output: process.stdout });
|
|
2343
|
+
const ask = (prompt) => new Promise((resolve5) => {
|
|
2344
|
+
readline.question(prompt, resolve5);
|
|
2345
|
+
readline.once("close", () => resolve5(void 0));
|
|
2346
|
+
});
|
|
2347
|
+
const root = process.cwd();
|
|
2348
|
+
let running = true;
|
|
2349
|
+
while (running) {
|
|
2350
|
+
const line = await ask("> ");
|
|
2351
|
+
if (line === void 0) break;
|
|
2352
|
+
const input = line.trim();
|
|
2353
|
+
if (input === "") continue;
|
|
2354
|
+
try {
|
|
2355
|
+
running = await handleInput({ input, context, state, log, root, ask });
|
|
2356
|
+
} catch (error) {
|
|
2357
|
+
const message2 = error instanceof Error ? error.message : String(error);
|
|
2358
|
+
log.append({ kind: "error", at: (/* @__PURE__ */ new Date()).toISOString(), message: message2 });
|
|
2359
|
+
context.error(` ${message2}`);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
readline.close();
|
|
2363
|
+
const totals = totalUsage(log.read());
|
|
2364
|
+
log.append({ kind: "session.ended", at: (/* @__PURE__ */ new Date()).toISOString(), turns: totals.turns });
|
|
2365
|
+
context.print(
|
|
2366
|
+
`
|
|
2367
|
+
${totals.turns} turn${totals.turns === 1 ? "" : "s"}` + (totals.inputTokens + totals.outputTokens > 0 ? `, ${totals.inputTokens + totals.outputTokens} tokens` : "") + `
|
|
2368
|
+
Transcript: ${log.path}
|
|
2369
|
+
Resume with: pm chat --resume ${id}
|
|
2370
|
+
`
|
|
2371
|
+
);
|
|
2372
|
+
return 0;
|
|
2373
|
+
}
|
|
2374
|
+
async function handleInput(args) {
|
|
2375
|
+
const { input, context, state, log, root } = args;
|
|
2376
|
+
if (!input.startsWith("/")) {
|
|
2377
|
+
await answer2(args);
|
|
2378
|
+
return true;
|
|
2379
|
+
}
|
|
2380
|
+
const [command, ...rest] = input.slice(1).split(/\s+/);
|
|
2381
|
+
const argument = rest.join(" ").trim();
|
|
2382
|
+
switch (command) {
|
|
2383
|
+
case "exit":
|
|
2384
|
+
case "quit":
|
|
2385
|
+
return false;
|
|
2386
|
+
case "help":
|
|
2387
|
+
context.print(HELP2);
|
|
2388
|
+
return true;
|
|
2389
|
+
case "usage": {
|
|
2390
|
+
const totals = totalUsage(log.read());
|
|
2391
|
+
context.print(
|
|
2392
|
+
` ${totals.turns} turns, ${totals.inputTokens} in / ${totals.outputTokens} out tokens`
|
|
2393
|
+
);
|
|
2394
|
+
return true;
|
|
2395
|
+
}
|
|
2396
|
+
case "new":
|
|
2397
|
+
delete state.conversationId;
|
|
2398
|
+
state.turns = [];
|
|
2399
|
+
state.attached = [];
|
|
2400
|
+
delete state.lastReply;
|
|
2401
|
+
context.print(" Starting a fresh conversation.");
|
|
2402
|
+
return true;
|
|
2403
|
+
case "read":
|
|
2404
|
+
case "capture": {
|
|
2405
|
+
if (!argument) {
|
|
2406
|
+
context.error(` /${command} needs a path.`);
|
|
2407
|
+
return true;
|
|
2408
|
+
}
|
|
2409
|
+
const file = readWithin(root, argument);
|
|
2410
|
+
state.attached.push({ path: file.path, text: file.text });
|
|
2411
|
+
log.append({
|
|
2412
|
+
kind: "file.read",
|
|
2413
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2414
|
+
path: file.path,
|
|
2415
|
+
bytes: file.bytes
|
|
2416
|
+
});
|
|
2417
|
+
context.print(` read ${relative2(root, file.path)} (${Math.round(file.bytes / 1024)} KB)`);
|
|
2418
|
+
if (command === "capture") {
|
|
2419
|
+
const client = await context.client();
|
|
2420
|
+
const result = await client.memories.remember(
|
|
2421
|
+
{ text: file.text, title: relative2(root, file.path) },
|
|
2422
|
+
{ idempotencyKey: `cli:capture:${file.path}:${file.bytes}` }
|
|
2423
|
+
);
|
|
2424
|
+
log.append({
|
|
2425
|
+
kind: "file.captured",
|
|
2426
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2427
|
+
path: file.path,
|
|
2428
|
+
jobId: result.jobId
|
|
2429
|
+
});
|
|
2430
|
+
context.print(` queued for extraction (job ${result.jobId})`);
|
|
2431
|
+
}
|
|
2432
|
+
return true;
|
|
2433
|
+
}
|
|
2434
|
+
case "remember": {
|
|
2435
|
+
if (!argument) {
|
|
2436
|
+
context.error(" /remember needs something to remember.");
|
|
2437
|
+
return true;
|
|
2438
|
+
}
|
|
2439
|
+
const client = await context.client();
|
|
2440
|
+
const result = await client.memories.remember({ text: argument });
|
|
2441
|
+
context.print(` queued for extraction (job ${result.jobId})`);
|
|
2442
|
+
return true;
|
|
2443
|
+
}
|
|
2444
|
+
case "write":
|
|
2445
|
+
await write({ ...args, path: argument });
|
|
2446
|
+
return true;
|
|
2447
|
+
default:
|
|
2448
|
+
context.error(` Unknown command /${command ?? ""}. Try /help.`);
|
|
2449
|
+
return true;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
async function answer2(args) {
|
|
2453
|
+
const { input, context, state, log } = args;
|
|
2454
|
+
const attached = state.attached.map((file) => `--- ${file.path} ---
|
|
2455
|
+
${file.text}`).join("\n\n");
|
|
2456
|
+
const content = attached ? `${attached}
|
|
2457
|
+
|
|
2458
|
+
---
|
|
2459
|
+
|
|
2460
|
+
${input}` : input;
|
|
2461
|
+
log.append({ kind: "prompt", at: (/* @__PURE__ */ new Date()).toISOString(), text: input });
|
|
2462
|
+
state.turns.push({ role: "user", content });
|
|
2463
|
+
const client = await context.client();
|
|
2464
|
+
const response = await client.request("POST", "/api/v1/chat", {
|
|
2465
|
+
messages: state.turns.slice(-20),
|
|
2466
|
+
...state.conversationId ? { conversationId: state.conversationId } : {}
|
|
2467
|
+
});
|
|
2468
|
+
const reply = response.message.content;
|
|
2469
|
+
state.turns.push({ role: "assistant", content: reply });
|
|
2470
|
+
state.lastReply = reply;
|
|
2471
|
+
if (response.conversationId) state.conversationId = response.conversationId;
|
|
2472
|
+
state.attached = [];
|
|
2473
|
+
log.append({
|
|
2474
|
+
kind: "reply",
|
|
2475
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2476
|
+
text: reply,
|
|
2477
|
+
citations: response.citations?.length ?? 0,
|
|
2478
|
+
...response.diagnostics?.usage ? { usage: response.diagnostics.usage } : {},
|
|
2479
|
+
...response.diagnostics?.model ? { model: response.diagnostics.model } : {}
|
|
2480
|
+
});
|
|
2481
|
+
context.print(`
|
|
2482
|
+
${reply}
|
|
2483
|
+
`);
|
|
2484
|
+
if (response.citations?.length) {
|
|
2485
|
+
const historical = response.citations.filter((one) => one.historical).length;
|
|
2486
|
+
context.print(
|
|
2487
|
+
` from ${response.citations.length} memor${response.citations.length === 1 ? "y" : "ies"}` + // Never omitted. A superseded memory presented as current is the most
|
|
2488
|
+
// damaging thing this system can do.
|
|
2489
|
+
(historical > 0 ? `, ${historical} no longer current` : "")
|
|
2490
|
+
);
|
|
2491
|
+
}
|
|
2492
|
+
if (response.diagnostics?.degraded && !context.flags.quiet) {
|
|
2493
|
+
context.error(" Note: answered without the semantic index, so this may be narrower than usual.");
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
async function write(args) {
|
|
2497
|
+
const { context, state, log, root } = args;
|
|
2498
|
+
if (!args.path) {
|
|
2499
|
+
context.error(" /write needs a path.");
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
if (!state.lastReply) {
|
|
2503
|
+
context.error(" Nothing to write yet \u2014 ask something first.");
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
const proposed = proposeWrite(root, args.path, `${state.lastReply}
|
|
2507
|
+
`);
|
|
2508
|
+
context.print("");
|
|
2509
|
+
context.print(summarise(proposed));
|
|
2510
|
+
context.print("");
|
|
2511
|
+
const reply = (await args.ask(" Write it? [y/N] "))?.trim().toLowerCase();
|
|
2512
|
+
const approved = reply === "y" || reply === "yes";
|
|
2513
|
+
log.append({
|
|
2514
|
+
kind: "file.write",
|
|
2515
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2516
|
+
path: proposed.path,
|
|
2517
|
+
approved,
|
|
2518
|
+
...approved ? { bytes: Buffer.byteLength(proposed.contents) } : {}
|
|
2519
|
+
});
|
|
2520
|
+
if (!approved) {
|
|
2521
|
+
context.print(" Not written.");
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
commitWrite(proposed, true);
|
|
2525
|
+
context.print(` Wrote ${relative2(root, proposed.path)}.`);
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// src/commands/memory.ts
|
|
2529
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
2530
|
+
var memoryColumns = [
|
|
2531
|
+
{ header: "id", value: (m) => m.id },
|
|
2532
|
+
{ header: "type", value: (m) => m.type },
|
|
2533
|
+
{ header: "title", value: (m) => m.title },
|
|
2534
|
+
{ header: "confidence", value: (m) => m.confidence.toFixed(2) },
|
|
2535
|
+
{ header: "updated", value: (m) => shortDate(m.updatedAt) }
|
|
2536
|
+
];
|
|
2537
|
+
var memoryFields = [
|
|
2538
|
+
{ header: "id", value: (m) => m.id },
|
|
2539
|
+
{ header: "type", value: (m) => m.type },
|
|
2540
|
+
{ header: "state", value: (m) => m.state },
|
|
2541
|
+
{ header: "title", value: (m) => m.title },
|
|
2542
|
+
{ header: "content", value: (m) => m.content },
|
|
2543
|
+
{ header: "confidence", value: (m) => m.confidence.toFixed(2) },
|
|
2544
|
+
{ header: "importance", value: (m) => m.importance.toFixed(2) },
|
|
2545
|
+
{ header: "entities", value: (m) => m.entities.map((e) => e.name).join(", ") },
|
|
2546
|
+
{ header: "spaces", value: (m) => (m.spaceIds ?? []).join(", ") },
|
|
2547
|
+
{ header: "version", value: (m) => String(m.version) },
|
|
2548
|
+
{ header: "created", value: (m) => shortDate(m.createdAt) },
|
|
2549
|
+
{ header: "updated", value: (m) => shortDate(m.updatedAt) }
|
|
2550
|
+
];
|
|
2551
|
+
async function rememberCommand(context) {
|
|
2552
|
+
const positional = context.args.words.slice(1);
|
|
2553
|
+
const file = stringFlag(context.args, "file", "f");
|
|
2554
|
+
let text;
|
|
2555
|
+
if (file) {
|
|
2556
|
+
try {
|
|
2557
|
+
text = readFileSync5(file, "utf8");
|
|
2558
|
+
} catch {
|
|
2559
|
+
context.error(`Could not read ${file}.`);
|
|
2560
|
+
return 1;
|
|
2561
|
+
}
|
|
2562
|
+
} else if (positional[0] === "-" || positional.length === 0 && !process.stdin.isTTY) {
|
|
2563
|
+
text = await readStdin();
|
|
2564
|
+
} else {
|
|
2565
|
+
text = positional.join(" ");
|
|
2566
|
+
}
|
|
2567
|
+
if (text.trim() === "") {
|
|
2568
|
+
context.error("Nothing to remember. Pass text, a --file, or pipe something in.");
|
|
2569
|
+
return 2;
|
|
2570
|
+
}
|
|
2571
|
+
const client = await context.client();
|
|
2572
|
+
const spaceIds = listFlag(context.args, "space", "spaces");
|
|
2573
|
+
const title = stringFlag(context.args, "title");
|
|
2574
|
+
const result = await client.memories.remember(
|
|
2575
|
+
{
|
|
2576
|
+
text,
|
|
2577
|
+
...title ? { title } : {},
|
|
2578
|
+
...spaceIds ? { spaceIds } : {}
|
|
2579
|
+
},
|
|
2580
|
+
{
|
|
2581
|
+
/**
|
|
2582
|
+
* A key derived from the CONTENT, not a random one.
|
|
2583
|
+
*
|
|
2584
|
+
* The SDK will not retry a POST without one, and a random value per
|
|
2585
|
+
* attempt would defeat the point: a request that timed out after the
|
|
2586
|
+
* server accepted it would be captured twice. Same text, same key, one
|
|
2587
|
+
* memory — which is what a person re-running a failed command expects.
|
|
2588
|
+
*/
|
|
2589
|
+
idempotencyKey: `cli:remember:${hash(text)}`
|
|
2590
|
+
}
|
|
2591
|
+
);
|
|
2592
|
+
if (context.flags.output !== "table") {
|
|
2593
|
+
context.print(renderOne(result, [
|
|
2594
|
+
{ header: "status", value: (r) => r.status },
|
|
2595
|
+
{ header: "jobId", value: (r) => r.jobId },
|
|
2596
|
+
{ header: "note", value: (r) => r.note }
|
|
2597
|
+
], { format: context.flags.output }));
|
|
2598
|
+
return 0;
|
|
2599
|
+
}
|
|
2600
|
+
context.print(`Accepted for processing. Job ${result.jobId}.`);
|
|
2601
|
+
context.print(result.note);
|
|
2602
|
+
return 0;
|
|
2603
|
+
}
|
|
2604
|
+
async function searchCommand(context) {
|
|
2605
|
+
const query = context.args.words.slice(1).join(" ").trim();
|
|
2606
|
+
if (query === "") {
|
|
2607
|
+
context.error('Nothing to search for. Try `pm search "what did we decide about Postgres"`.');
|
|
2608
|
+
return 2;
|
|
2609
|
+
}
|
|
2610
|
+
const limit = numberFlag(context.args, "limit", "n");
|
|
2611
|
+
if (limit === "invalid") {
|
|
2612
|
+
context.error("--limit must be a number.");
|
|
2613
|
+
return 2;
|
|
2614
|
+
}
|
|
2615
|
+
const client = await context.client();
|
|
2616
|
+
const spaceIds = listFlag(context.args, "space", "spaces");
|
|
2617
|
+
const response = await client.search.query({
|
|
2618
|
+
query,
|
|
2619
|
+
...limit !== void 0 ? { limit } : {},
|
|
2620
|
+
...spaceIds ? { spaceIds } : {},
|
|
2621
|
+
...stringFlag(context.args, "as-of") ? { asOf: stringFlag(context.args, "as-of") } : {}
|
|
2622
|
+
});
|
|
2623
|
+
if (context.flags.output === "json" || context.flags.output === "yaml") {
|
|
2624
|
+
context.print(renderOne(response, [], { format: context.flags.output }));
|
|
2625
|
+
return 0;
|
|
2626
|
+
}
|
|
2627
|
+
const columns = [
|
|
2628
|
+
{ header: "score", value: (r) => r.score.toFixed(3) },
|
|
2629
|
+
{ header: "type", value: (r) => r.memory.type },
|
|
2630
|
+
{ header: "title", value: (r) => r.memory.title },
|
|
2631
|
+
{ header: "content", value: (r) => r.memory.content },
|
|
2632
|
+
{ header: "id", value: (r) => r.memory.id }
|
|
2633
|
+
];
|
|
2634
|
+
if (response.results.length === 0) {
|
|
2635
|
+
context.print(`Nothing found for "${response.query}".`);
|
|
2636
|
+
} else {
|
|
2637
|
+
context.print(render(response.results, columns, { format: context.flags.output }));
|
|
2638
|
+
}
|
|
2639
|
+
if (response.diagnostics.degraded && !context.flags.quiet) {
|
|
2640
|
+
const notice = response.diagnostics.notice ?? `search ran without ${(response.diagnostics.unavailable ?? ["some capabilities"]).join(", ")}`;
|
|
2641
|
+
context.error(`
|
|
2642
|
+
Note: these results are narrower than usual \u2014 ${notice}`);
|
|
2643
|
+
}
|
|
2644
|
+
return 0;
|
|
2645
|
+
}
|
|
2646
|
+
async function listMemoriesCommand(context) {
|
|
2647
|
+
const limit = numberFlag(context.args, "limit", "n");
|
|
2648
|
+
if (limit === "invalid") {
|
|
2649
|
+
context.error("--limit must be a number.");
|
|
2650
|
+
return 2;
|
|
2651
|
+
}
|
|
2652
|
+
const client = await context.client();
|
|
2653
|
+
const page = client.memories.list({
|
|
2654
|
+
...limit !== void 0 ? { limit } : {},
|
|
2655
|
+
...listFlag(context.args, "type") ? { type: listFlag(context.args, "type") } : {},
|
|
2656
|
+
...listFlag(context.args, "space", "spaces") ? { spaceIds: listFlag(context.args, "space", "spaces") } : {}
|
|
2657
|
+
});
|
|
2658
|
+
const rows = context.args.flags["all"] ? await page.all(limit ?? 1e3) : (await page.first()).data;
|
|
2659
|
+
if (rows.length === 0) {
|
|
2660
|
+
context.print("No memories yet.");
|
|
2661
|
+
return 0;
|
|
2662
|
+
}
|
|
2663
|
+
context.print(render(rows, memoryColumns, { format: context.flags.output }));
|
|
2664
|
+
return 0;
|
|
2665
|
+
}
|
|
2666
|
+
async function getMemoryCommand(context) {
|
|
2667
|
+
const id = context.args.words[2];
|
|
2668
|
+
if (!id) {
|
|
2669
|
+
context.error("Which memory? Try `pm get memory <id>`.");
|
|
2670
|
+
return 2;
|
|
2671
|
+
}
|
|
2672
|
+
const client = await context.client();
|
|
2673
|
+
const memory = await client.memories.get(id);
|
|
2674
|
+
context.print(renderOne(memory, memoryFields, { format: context.flags.output }));
|
|
2675
|
+
return 0;
|
|
2676
|
+
}
|
|
2677
|
+
async function listSpacesCommand(context) {
|
|
2678
|
+
const client = await context.client();
|
|
2679
|
+
const { data } = await client.spaces.list().first();
|
|
2680
|
+
if (data.length === 0) {
|
|
2681
|
+
context.print("No Spaces yet.");
|
|
2682
|
+
return 0;
|
|
2683
|
+
}
|
|
2684
|
+
const columns = [
|
|
2685
|
+
{ header: "id", value: (s) => s.id },
|
|
2686
|
+
{ header: "name", value: (s) => s.name },
|
|
2687
|
+
{ header: "kind", value: (s) => s.kind },
|
|
2688
|
+
{ header: "memories", value: (s) => s.memoryCount === void 0 ? "" : String(s.memoryCount) },
|
|
2689
|
+
{ header: "created", value: (s) => shortDate(s.createdAt) }
|
|
2690
|
+
];
|
|
2691
|
+
context.print(render(data, columns, { format: context.flags.output }));
|
|
2692
|
+
return 0;
|
|
2693
|
+
}
|
|
2694
|
+
async function statusCommand(context) {
|
|
2695
|
+
const client = await context.client();
|
|
2696
|
+
const health = await client.health.ready();
|
|
2697
|
+
context.print(
|
|
2698
|
+
renderOne(health, [{ header: "status", value: (h) => JSON.stringify(h) }], {
|
|
2699
|
+
format: context.flags.output === "table" ? "yaml" : context.flags.output
|
|
2700
|
+
})
|
|
2701
|
+
);
|
|
2702
|
+
return 0;
|
|
2703
|
+
}
|
|
2704
|
+
function hash(text) {
|
|
2705
|
+
let value = 2166136261;
|
|
2706
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
2707
|
+
value ^= text.charCodeAt(index);
|
|
2708
|
+
value = Math.imul(value, 16777619) >>> 0;
|
|
2709
|
+
}
|
|
2710
|
+
return value.toString(16).padStart(8, "0");
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
// src/index.ts
|
|
2714
|
+
async function run(deps) {
|
|
2715
|
+
const args = parseArgs(deps.argv);
|
|
2716
|
+
const print = deps.stdout ?? ((line) => process.stdout.write(`${line}
|
|
2717
|
+
`));
|
|
2718
|
+
const error = deps.stderr ?? ((line) => process.stderr.write(`${line}
|
|
2719
|
+
`));
|
|
2720
|
+
if (boolFlag(args, "version", "v")) {
|
|
2721
|
+
print(VERSION);
|
|
2722
|
+
return 0;
|
|
2723
|
+
}
|
|
2724
|
+
if (boolFlag(args, "help", "h")) {
|
|
2725
|
+
print(HELP);
|
|
2726
|
+
return 0;
|
|
2727
|
+
}
|
|
2728
|
+
const flags = globalFlags(args, deps);
|
|
2729
|
+
if (flags === "invalid-output") {
|
|
2730
|
+
error(`--output must be one of table, json, yaml, csv, tsv.`);
|
|
2731
|
+
return 2;
|
|
2732
|
+
}
|
|
2733
|
+
if (flags === "invalid-limit") {
|
|
2734
|
+
error("--limit must be a number.");
|
|
2735
|
+
return 2;
|
|
2736
|
+
}
|
|
2737
|
+
const paths = deps.paths ?? pathsFor();
|
|
2738
|
+
const resolved = resolve({
|
|
2739
|
+
paths,
|
|
2740
|
+
profileFlag: flags.profile,
|
|
2741
|
+
apiUrlFlag: flags.apiUrl,
|
|
2742
|
+
// A prompted key is not known yet; only a literal one participates here.
|
|
2743
|
+
apiKeyFlag: typeof flags.apiKey === "string" ? flags.apiKey : void 0,
|
|
2744
|
+
...deps.env ? { env: deps.env } : {}
|
|
2745
|
+
});
|
|
2746
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
2747
|
+
const oauth = {
|
|
2748
|
+
fetch: fetchImpl,
|
|
2749
|
+
...deps.openBrowser ? { openBrowser: deps.openBrowser } : {},
|
|
2750
|
+
print
|
|
2751
|
+
};
|
|
2752
|
+
const session = { ...oauth, paths, userAgent: `persistmemory-cli/${VERSION}` };
|
|
2753
|
+
const context = {
|
|
2754
|
+
args,
|
|
2755
|
+
flags,
|
|
2756
|
+
paths,
|
|
2757
|
+
resolved,
|
|
2758
|
+
oauth,
|
|
2759
|
+
session,
|
|
2760
|
+
client: () => clientFor(resolved, session),
|
|
2761
|
+
print,
|
|
2762
|
+
error,
|
|
2763
|
+
readSecret: deps.readSecret ?? readSecretFromTty
|
|
2764
|
+
};
|
|
2765
|
+
if (args.words.length === 0) {
|
|
2766
|
+
if (!(deps.isTty ?? process.stdin.isTTY ?? false)) {
|
|
2767
|
+
print(HELP);
|
|
2768
|
+
return 0;
|
|
2769
|
+
}
|
|
2770
|
+
return sessionCommand(context);
|
|
2771
|
+
}
|
|
2772
|
+
try {
|
|
2773
|
+
return await dispatch(context);
|
|
2774
|
+
} catch (caught) {
|
|
2775
|
+
return report(caught, error);
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
async function dispatch(context) {
|
|
2779
|
+
const [verb, noun] = context.args.words;
|
|
2780
|
+
switch (verb) {
|
|
2781
|
+
case "auth":
|
|
2782
|
+
return authCommand(context);
|
|
2783
|
+
case "agent":
|
|
2784
|
+
return agentCommand(context);
|
|
2785
|
+
case "chat":
|
|
2786
|
+
case "session":
|
|
2787
|
+
return sessionCommand(context);
|
|
2788
|
+
case "remember":
|
|
2789
|
+
return rememberCommand(context);
|
|
2790
|
+
case "search":
|
|
2791
|
+
return searchCommand(context);
|
|
2792
|
+
case "status":
|
|
2793
|
+
return statusCommand(context);
|
|
2794
|
+
case "requests":
|
|
2795
|
+
return requestsCommand(context);
|
|
2796
|
+
/**
|
|
2797
|
+
* `pm <verb> <noun>`, the grammar the Harness CLI uses.
|
|
2798
|
+
*
|
|
2799
|
+
* Worth copying rather than inventing: a person who has typed
|
|
2800
|
+
* `list pipelines` can guess `list memories` without opening the help, and
|
|
2801
|
+
* a consistent grammar is what lets a tool grow past the handful of
|
|
2802
|
+
* commands anybody can memorise.
|
|
2803
|
+
*/
|
|
2804
|
+
case "list":
|
|
2805
|
+
if (noun === "memories" || noun === "memory") return listMemoriesCommand(context);
|
|
2806
|
+
if (noun === "spaces" || noun === "space") return listSpacesCommand(context);
|
|
2807
|
+
context.error(`Cannot list "${noun ?? ""}". Try memories or spaces.`);
|
|
2808
|
+
return 2;
|
|
2809
|
+
case "get":
|
|
2810
|
+
if (noun === "memory") return getMemoryCommand(context);
|
|
2811
|
+
context.error(`Cannot get "${noun ?? ""}". Try memory.`);
|
|
2812
|
+
return 2;
|
|
2813
|
+
default:
|
|
2814
|
+
context.error(`Unknown command "${verb ?? ""}". Run \`pm --help\`.`);
|
|
2815
|
+
return 2;
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
function globalFlags(args, deps) {
|
|
2819
|
+
const requested = stringFlag(args, "output", "o");
|
|
2820
|
+
if (requested !== void 0 && !isOutputFormat(requested)) return "invalid-output";
|
|
2821
|
+
const limit = numberFlag(args, "limit", "n");
|
|
2822
|
+
if (limit === "invalid") return "invalid-limit";
|
|
2823
|
+
const isTty = deps.isTty ?? process.stdout.isTTY ?? false;
|
|
2824
|
+
const output = requested ?? (isTty ? "table" : "json");
|
|
2825
|
+
const apiKey = args.flags["api-key"];
|
|
2826
|
+
const profile = stringFlag(args, "profile", "p");
|
|
2827
|
+
const apiUrl = stringFlag(args, "api-url");
|
|
2828
|
+
return {
|
|
2829
|
+
...profile !== void 0 ? { profile } : {},
|
|
2830
|
+
...apiUrl !== void 0 ? { apiUrl } : {},
|
|
2831
|
+
...apiKey === true || typeof apiKey === "string" ? { apiKey } : {},
|
|
2832
|
+
output,
|
|
2833
|
+
quiet: boolFlag(args, "quiet", "q"),
|
|
2834
|
+
...limit !== void 0 ? { limit } : {}
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
function report(caught, error) {
|
|
2838
|
+
if (caught instanceof NotSignedIn) {
|
|
2839
|
+
error(caught.message);
|
|
2840
|
+
return 3;
|
|
2841
|
+
}
|
|
2842
|
+
if (caught instanceof PersistMemoryError) {
|
|
2843
|
+
error(caught.message);
|
|
2844
|
+
return caught.status === 401 || caught.status === 403 ? 3 : 1;
|
|
2845
|
+
}
|
|
2846
|
+
error(caught instanceof Error ? caught.message : String(caught));
|
|
2847
|
+
return 1;
|
|
2848
|
+
}
|
|
2849
|
+
export {
|
|
2850
|
+
PersistMemory,
|
|
2851
|
+
run
|
|
2852
|
+
};
|
|
2853
|
+
//# sourceMappingURL=index.js.map
|