engineering-memory 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.
Files changed (40) hide show
  1. package/bin/engineering-memory.mjs +120 -0
  2. package/dispatcher/managed-section.mjs +59 -0
  3. package/dispatcher/sections.mjs +14 -0
  4. package/install/api-url.mjs +39 -0
  5. package/install/cli.mjs +93 -0
  6. package/install/commands.mjs +140 -0
  7. package/install/files.mjs +416 -0
  8. package/install/git-hook.mjs +270 -0
  9. package/install/installer.mjs +279 -0
  10. package/install/mcp-registration.mjs +457 -0
  11. package/package.json +28 -0
  12. package/runtime/dist/src/auth/browser-auth.js +184 -0
  13. package/runtime/dist/src/auth/credential-store.js +181 -0
  14. package/runtime/dist/src/cache/etag-cache.js +123 -0
  15. package/runtime/dist/src/config.js +59 -0
  16. package/runtime/dist/src/git/git-inspector.js +375 -0
  17. package/runtime/dist/src/git/pre-commit.js +44 -0
  18. package/runtime/dist/src/git/verification-gate.js +221 -0
  19. package/runtime/dist/src/index.js +60 -0
  20. package/runtime/dist/src/journal/journal-store.js +1300 -0
  21. package/runtime/dist/src/mcp/server.js +11 -0
  22. package/runtime/dist/src/mcp/tool-definitions.js +405 -0
  23. package/runtime/dist/src/project/repository.js +79 -0
  24. package/runtime/dist/src/runtime/active-context-store.js +356 -0
  25. package/runtime/dist/src/runtime/api-client.js +229 -0
  26. package/runtime/dist/src/runtime/bridge-service.js +2226 -0
  27. package/runtime/dist/src/runtime/offline-outbox.js +274 -0
  28. package/runtime/dist/src/runtime/principal-state.js +97 -0
  29. package/runtime/dist/src/types.js +2 -0
  30. package/runtime/dist/src/utilities/files.js +189 -0
  31. package/runtime/dist/src/utilities/hash.js +19 -0
  32. package/runtime/dist/src/utilities/process.js +32 -0
  33. package/runtime/package-lock.json +137 -0
  34. package/runtime/package.json +32 -0
  35. package/skill/SKILL.md +29 -0
  36. package/skill/agents/openai.yaml +6 -0
  37. package/skill/references/lifecycle.md +102 -0
  38. package/skill/references/memory-updates.md +25 -0
  39. package/skill/references/questionnaires.md +98 -0
  40. package/skill/references/scaffolding.md +38 -0
@@ -0,0 +1,356 @@
1
+ import { join } from 'node:path';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { ensureManagedDirectory, readJson, removeFile, writeJson } from '../utilities/files.js';
4
+ import { sha256, stableStringify } from '../utilities/hash.js';
5
+ import { assertSafeToPersist } from './offline-outbox.js';
6
+ export class ActiveContextStore {
7
+ root;
8
+ queues = new Map();
9
+ constructor(stateRoot) {
10
+ this.root = join(stateRoot, 'active-contexts');
11
+ }
12
+ async load(repoFingerprint) {
13
+ assertFingerprint(repoFingerprint);
14
+ const pointer = await readJson(this.pathFor(repoFingerprint), this.root);
15
+ if (!pointer) {
16
+ return null;
17
+ }
18
+ this.validate(pointer, repoFingerprint);
19
+ return pointer;
20
+ }
21
+ async save(input) {
22
+ await this.exclusive(input.repoFingerprint, async () => {
23
+ const existing = await readJson(this.pathFor(input.repoFingerprint), this.root);
24
+ const sameTask = existing?.taskId === input.taskId && existing.sessionId === input.sessionId;
25
+ if (!sameTask && (existing?.verificationIntent || existing?.closeIntent)) {
26
+ throw new Error('Pending lifecycle intent prevents replacing the active task pointer');
27
+ }
28
+ const { verificationIntent: _verificationIntent, closeIntent: _closeIntent, ...pointerInput } = input;
29
+ await this.writePointer({
30
+ ...pointerInput,
31
+ ...(sameTask && existing?.verificationIntent
32
+ ? { verificationIntent: existing.verificationIntent }
33
+ : {}),
34
+ ...(sameTask && existing?.closeIntent ? { closeIntent: existing.closeIntent } : {}),
35
+ });
36
+ });
37
+ }
38
+ async updateTaskSnapshot(taskId, taskVersion, lastSequence) {
39
+ await ensureManagedDirectory(this.root, this.root);
40
+ const entries = await readdir(this.root, { withFileTypes: true });
41
+ for (const entry of entries) {
42
+ if (entry.name.endsWith('.json') && (!entry.isFile() || entry.isSymbolicLink())) {
43
+ throw new Error(`Unsafe active context entry: ${entry.name}`);
44
+ }
45
+ if (!entry.isFile() || !entry.name.endsWith('.json')) {
46
+ continue;
47
+ }
48
+ const pointer = await readJson(join(this.root, entry.name), this.root);
49
+ if (pointer?.taskId === taskId) {
50
+ await this.save({
51
+ ...pointer,
52
+ taskVersion,
53
+ lastSequence,
54
+ });
55
+ }
56
+ }
57
+ }
58
+ async setChangeBaseline(repoFingerprint, sessionId, manifest, leasePaths, taskSnapshot) {
59
+ const pointer = await this.load(repoFingerprint);
60
+ if (!pointer ||
61
+ pointer.sessionId !== sessionId ||
62
+ (taskSnapshot && pointer.taskId !== taskSnapshot.taskId)) {
63
+ throw new Error('Change baseline does not match the active repository session');
64
+ }
65
+ if (pointer.changeBaseline?.leasePaths.some((path) => !leasePaths.includes(path))) {
66
+ throw new Error('Change lease paths cannot be narrowed after editing has started');
67
+ }
68
+ await this.save({
69
+ ...pointer,
70
+ ...(taskSnapshot
71
+ ? {
72
+ taskVersion: taskSnapshot.taskVersion,
73
+ lastSequence: taskSnapshot.lastSequence,
74
+ }
75
+ : {}),
76
+ changeBaseline: pointer.changeBaseline
77
+ ? { ...pointer.changeBaseline, leasePaths }
78
+ : {
79
+ diffHash: manifest.diffHash,
80
+ changedPaths: manifest.changedPaths,
81
+ leasePaths,
82
+ },
83
+ });
84
+ }
85
+ async setVerificationIntent(repoFingerprint, input) {
86
+ await this.exclusive(repoFingerprint, async () => {
87
+ const pointer = await this.readPointer(repoFingerprint);
88
+ const bodyHash = sha256(stableStringify(input.body));
89
+ if (!pointer ||
90
+ pointer.taskId !== input.body.taskId ||
91
+ pointer.sessionId !== input.body.sessionId ||
92
+ pointer.closeIntent) {
93
+ throw new Error('Verification intent does not match the active task');
94
+ }
95
+ if (pointer.verificationIntent) {
96
+ if (pointer.verificationIntent.bodyHash === bodyHash &&
97
+ stableStringify(pointer.verificationIntent.taskChanges) ===
98
+ stableStringify(input.taskChanges)) {
99
+ return;
100
+ }
101
+ throw new Error('A different verification intent is already pending');
102
+ }
103
+ await this.writePointer({
104
+ ...pointer,
105
+ verificationIntent: {
106
+ ...input,
107
+ bodyHash,
108
+ createdAt: new Date().toISOString(),
109
+ },
110
+ });
111
+ });
112
+ }
113
+ async clearVerificationIntent(repoFingerprint) {
114
+ await this.exclusive(repoFingerprint, async () => {
115
+ const pointer = await this.readPointer(repoFingerprint);
116
+ if (!pointer?.verificationIntent) {
117
+ return;
118
+ }
119
+ const { verificationIntent: _verificationIntent, ...next } = pointer;
120
+ await this.writePointer(next);
121
+ });
122
+ }
123
+ async setCloseIntent(repoFingerprint, input) {
124
+ await this.exclusive(repoFingerprint, async () => {
125
+ const pointer = await this.readPointer(repoFingerprint);
126
+ const bodyHash = sha256(stableStringify(input.body));
127
+ if (!pointer ||
128
+ pointer.taskId !== input.body.taskId ||
129
+ pointer.sessionId !== input.body.sessionId ||
130
+ pointer.verificationIntent) {
131
+ throw new Error('Close intent does not match the active task');
132
+ }
133
+ if (pointer.closeIntent) {
134
+ if (pointer.closeIntent.bodyHash === bodyHash &&
135
+ stableStringify(pointer.closeIntent.taskChanges) === stableStringify(input.taskChanges)) {
136
+ return;
137
+ }
138
+ throw new Error('A different close intent is already pending');
139
+ }
140
+ await this.writePointer({
141
+ ...pointer,
142
+ closeIntent: {
143
+ ...input,
144
+ bodyHash,
145
+ createdAt: new Date().toISOString(),
146
+ },
147
+ });
148
+ });
149
+ }
150
+ async clearCloseIntent(repoFingerprint) {
151
+ await this.exclusive(repoFingerprint, async () => {
152
+ const pointer = await this.readPointer(repoFingerprint);
153
+ if (!pointer?.closeIntent) {
154
+ return;
155
+ }
156
+ const { closeIntent: _closeIntent, ...next } = pointer;
157
+ await this.writePointer(next);
158
+ });
159
+ }
160
+ async findByTaskId(taskId, projectId, requireClean = true) {
161
+ await ensureManagedDirectory(this.root, this.root);
162
+ const entries = await readdir(this.root, { withFileTypes: true });
163
+ const matches = [];
164
+ for (const entry of entries) {
165
+ if (entry.name.endsWith('.json') && (!entry.isFile() || entry.isSymbolicLink())) {
166
+ throw new Error(`Unsafe active context entry: ${entry.name}`);
167
+ }
168
+ if (!entry.isFile() || !entry.name.endsWith('.json')) {
169
+ continue;
170
+ }
171
+ const pointer = await readJson(join(this.root, entry.name), this.root);
172
+ if (pointer) {
173
+ this.validate(pointer, pointer.repoFingerprint);
174
+ if (pointer.taskId === taskId && pointer.projectId === projectId) {
175
+ matches.push(pointer);
176
+ }
177
+ }
178
+ }
179
+ if (matches.length !== 1 ||
180
+ (requireClean &&
181
+ (matches[0].resumeConflicts.length > 0 ||
182
+ Boolean(matches[0].verificationIntent) ||
183
+ Boolean(matches[0].closeIntent)))) {
184
+ throw new Error('A unique clean active task pointer is required');
185
+ }
186
+ return matches[0];
187
+ }
188
+ async markTasksConflict(taskIds, conflict) {
189
+ if (taskIds.length === 0) {
190
+ return;
191
+ }
192
+ await ensureManagedDirectory(this.root, this.root);
193
+ const targetIds = new Set(taskIds);
194
+ const entries = await readdir(this.root, { withFileTypes: true });
195
+ for (const entry of entries) {
196
+ if (entry.name.endsWith('.json') && (!entry.isFile() || entry.isSymbolicLink())) {
197
+ throw new Error(`Unsafe active context entry: ${entry.name}`);
198
+ }
199
+ if (!entry.isFile() || !entry.name.endsWith('.json')) {
200
+ continue;
201
+ }
202
+ const pointer = await readJson(join(this.root, entry.name), this.root);
203
+ if (pointer && targetIds.has(pointer.taskId)) {
204
+ await this.save({
205
+ ...pointer,
206
+ resumeConflicts: [...new Set([...pointer.resumeConflicts, conflict])],
207
+ });
208
+ }
209
+ }
210
+ }
211
+ async clearSessionConflict(sessionId, conflict) {
212
+ if (!isUuid(sessionId)) {
213
+ throw new Error('Context session identifier is invalid');
214
+ }
215
+ await ensureManagedDirectory(this.root, this.root);
216
+ const entries = await readdir(this.root, { withFileTypes: true });
217
+ for (const entry of entries) {
218
+ if (entry.name.endsWith('.json') && (!entry.isFile() || entry.isSymbolicLink())) {
219
+ throw new Error(`Unsafe active context entry: ${entry.name}`);
220
+ }
221
+ if (!entry.isFile() || !entry.name.endsWith('.json')) {
222
+ continue;
223
+ }
224
+ const pointer = await readJson(join(this.root, entry.name), this.root);
225
+ if (pointer?.sessionId === sessionId) {
226
+ await this.save({
227
+ ...pointer,
228
+ resumeConflicts: pointer.resumeConflicts.filter((value) => value !== conflict),
229
+ });
230
+ }
231
+ }
232
+ }
233
+ async clear() {
234
+ await ensureManagedDirectory(this.root, this.root);
235
+ const entries = await readdir(this.root, { withFileTypes: true });
236
+ for (const entry of entries) {
237
+ if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith('.json')) {
238
+ throw new Error(`Unsafe active context entry: ${entry.name}`);
239
+ }
240
+ await removeFile(join(this.root, entry.name), this.root);
241
+ }
242
+ }
243
+ pathFor(repoFingerprint) {
244
+ assertFingerprint(repoFingerprint);
245
+ return join(this.root, `${repoFingerprint}.json`);
246
+ }
247
+ async readPointer(repoFingerprint) {
248
+ return await readJson(this.pathFor(repoFingerprint), this.root);
249
+ }
250
+ async writePointer(input) {
251
+ const pointer = {
252
+ schemaVersion: 1,
253
+ ...input,
254
+ updatedAt: new Date().toISOString(),
255
+ };
256
+ this.validate(pointer, input.repoFingerprint);
257
+ assertSafeToPersist(JSON.parse(JSON.stringify(pointer)));
258
+ await writeJson(this.pathFor(input.repoFingerprint), pointer, this.root);
259
+ }
260
+ async exclusive(repoFingerprint, action) {
261
+ const previous = this.queues.get(repoFingerprint) ?? Promise.resolve();
262
+ let release = () => undefined;
263
+ const current = new Promise((resolvePromise) => {
264
+ release = resolvePromise;
265
+ });
266
+ const tail = previous.then(() => current);
267
+ this.queues.set(repoFingerprint, tail);
268
+ await previous;
269
+ try {
270
+ return await action();
271
+ }
272
+ finally {
273
+ release();
274
+ if (this.queues.get(repoFingerprint) === tail) {
275
+ this.queues.delete(repoFingerprint);
276
+ }
277
+ }
278
+ }
279
+ validate(pointer, expectedFingerprint) {
280
+ if (pointer.schemaVersion !== 1 ||
281
+ pointer.repoFingerprint !== expectedFingerprint ||
282
+ !isUuid(pointer.projectId) ||
283
+ !isUuid(pointer.taskId) ||
284
+ !isUuid(pointer.sessionId) ||
285
+ !Number.isInteger(pointer.lastSequence) ||
286
+ pointer.lastSequence < 0 ||
287
+ !Number.isInteger(pointer.taskVersion) ||
288
+ pointer.taskVersion < 1 ||
289
+ !Array.isArray(pointer.resumeConflicts)) {
290
+ throw new Error('Active Engineering Memory context pointer is invalid');
291
+ }
292
+ if (pointer.changeBaseline) {
293
+ if (!/^[0-9a-f]{64}$/.test(pointer.changeBaseline.diffHash) ||
294
+ !Array.isArray(pointer.changeBaseline.changedPaths) ||
295
+ pointer.changeBaseline.changedPaths.some((entry) => !isChangedPath(entry)) ||
296
+ !Array.isArray(pointer.changeBaseline.leasePaths) ||
297
+ pointer.changeBaseline.leasePaths.some((path) => !isRepositoryRelative(path)) ||
298
+ new Set(pointer.changeBaseline.leasePaths).size !== pointer.changeBaseline.leasePaths.length) {
299
+ throw new Error('Active Engineering Memory change baseline is invalid');
300
+ }
301
+ }
302
+ if ((pointer.verificationIntent && !isVerificationIntent(pointer.verificationIntent)) ||
303
+ (pointer.closeIntent && !isCloseIntent(pointer.closeIntent))) {
304
+ throw new Error('Active Engineering Memory mutation intent is invalid');
305
+ }
306
+ }
307
+ }
308
+ function isVerificationIntent(value) {
309
+ return ((value.mode === 'write' || value.mode === 'read_only' || value.mode === 'scaffold') &&
310
+ isDurableIntent(value) &&
311
+ value.body.taskId !== undefined &&
312
+ value.body.sessionId !== undefined);
313
+ }
314
+ function isCloseIntent(value) {
315
+ return (isDurableIntent(value) && value.body.taskId !== undefined && value.body.sessionId !== undefined);
316
+ }
317
+ function isDurableIntent(value) {
318
+ return (value.body !== null &&
319
+ typeof value.body === 'object' &&
320
+ !Array.isArray(value.body) &&
321
+ /^[0-9a-f]{64}$/.test(value.bodyHash) &&
322
+ value.bodyHash === sha256(stableStringify(value.body)) &&
323
+ Array.isArray(value.taskChanges) &&
324
+ value.taskChanges.every(isChangedPath) &&
325
+ typeof value.createdAt === 'string' &&
326
+ Number.isFinite(Date.parse(value.createdAt)));
327
+ }
328
+ function isChangedPath(value) {
329
+ return (typeof value.path === 'string' &&
330
+ isRepositoryRelative(value.path) &&
331
+ (value.originalPath === undefined || isRepositoryRelative(value.originalPath)) &&
332
+ typeof value.status === 'string' &&
333
+ value.status.length === 2 &&
334
+ (value.contentHash === null || /^[0-9a-f]{64}$/.test(value.contentHash)) &&
335
+ (value.size === null || (Number.isSafeInteger(value.size) && value.size >= 0)) &&
336
+ (value.mode === undefined ||
337
+ value.mode === null ||
338
+ value.mode === '100644' ||
339
+ value.mode === '100755'));
340
+ }
341
+ function isRepositoryRelative(value) {
342
+ const normalized = value.replace(/\\/g, '/');
343
+ return (normalized.length > 0 &&
344
+ !normalized.startsWith('/') &&
345
+ !/^[A-Za-z]:\//.test(normalized) &&
346
+ !normalized.split('/').includes('..'));
347
+ }
348
+ function assertFingerprint(value) {
349
+ if (!/^[0-9a-f]{64}$/.test(value)) {
350
+ throw new Error('Repository fingerprint is invalid');
351
+ }
352
+ }
353
+ function isUuid(value) {
354
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
355
+ }
356
+ //# sourceMappingURL=active-context-store.js.map
@@ -0,0 +1,229 @@
1
+ import { sha256, stableStringify } from '../utilities/hash.js';
2
+ export class BackendUnavailableError extends Error {
3
+ causeValue;
4
+ retryable = true;
5
+ constructor(message, causeValue) {
6
+ super(message);
7
+ this.causeValue = causeValue;
8
+ this.name = 'BackendUnavailableError';
9
+ }
10
+ }
11
+ export class ApiResponseError extends Error {
12
+ httpStatus;
13
+ code;
14
+ retryable;
15
+ envelope;
16
+ constructor(message, httpStatus, code, retryable, envelope) {
17
+ super(message);
18
+ this.httpStatus = httpStatus;
19
+ this.code = code;
20
+ this.retryable = retryable;
21
+ this.envelope = envelope;
22
+ this.name = 'ApiResponseError';
23
+ }
24
+ }
25
+ export class ApiClient {
26
+ options;
27
+ fetchImplementation;
28
+ responseSources = new WeakMap();
29
+ refreshPromise = null;
30
+ constructor(options) {
31
+ this.options = options;
32
+ this.fetchImplementation = options.fetchImplementation ?? fetch;
33
+ }
34
+ async request(path, request = {}) {
35
+ const method = request.method ?? 'GET';
36
+ const cacheKey = request.cacheKey ?? this.cacheKey(method, path, request.body);
37
+ const cached = request.cacheKey ? await this.options.cache.get(cacheKey) : null;
38
+ const headers = {
39
+ Accept: 'application/json',
40
+ ...request.headers,
41
+ };
42
+ if (request.body !== undefined) {
43
+ headers['Content-Type'] = 'application/json';
44
+ }
45
+ if (request.idempotencyKey) {
46
+ headers['Idempotency-Key'] = request.idempotencyKey;
47
+ }
48
+ if (cached?.etag) {
49
+ headers['If-None-Match'] = cached.etag;
50
+ }
51
+ if (request.authenticated !== false) {
52
+ const accessToken = await this.options.credentials.get('access-token');
53
+ if (!accessToken) {
54
+ throw new ApiResponseError('Authentication is required', 401, null, false, null);
55
+ }
56
+ headers.Authorization = `Bearer ${accessToken}`;
57
+ }
58
+ const controller = new AbortController();
59
+ const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs);
60
+ let response;
61
+ try {
62
+ response = await this.fetchImplementation(`${this.options.baseUrl}${path}`, {
63
+ method,
64
+ headers,
65
+ ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
66
+ signal: controller.signal,
67
+ });
68
+ }
69
+ catch (error) {
70
+ if (request.allowStaleOnUnavailable && cached) {
71
+ this.responseSources.set(cached.value, 'stale_cache');
72
+ return cached.value;
73
+ }
74
+ throw new BackendUnavailableError(error instanceof Error ? error.message : 'Backend request failed', error);
75
+ }
76
+ finally {
77
+ clearTimeout(timeout);
78
+ }
79
+ if (response.status === 304 && cached) {
80
+ this.responseSources.set(cached.value, 'not_modified');
81
+ return cached.value;
82
+ }
83
+ if (response.status === 401 &&
84
+ request.authenticated !== false &&
85
+ request.retryRefresh !== false) {
86
+ await this.refreshTokens();
87
+ return await this.request(path, { ...request, retryRefresh: false });
88
+ }
89
+ let envelope;
90
+ try {
91
+ envelope = await this.parseEnvelope(response);
92
+ }
93
+ catch (error) {
94
+ if (request.allowStaleOnUnavailable &&
95
+ cached &&
96
+ isBackendUnavailableStatus(response.status)) {
97
+ this.responseSources.set(cached.value, 'stale_cache');
98
+ return cached.value;
99
+ }
100
+ throw error;
101
+ }
102
+ if (!response.ok || envelope.status === 'error') {
103
+ if (request.allowStaleOnUnavailable &&
104
+ cached &&
105
+ isBackendUnavailableStatus(response.status)) {
106
+ this.responseSources.set(cached.value, 'stale_cache');
107
+ return cached.value;
108
+ }
109
+ throw new ApiResponseError(envelope.errorModel?.text ?? envelope.message ?? `Backend returned ${response.status}`, response.status, envelope.errorModel?.code ?? null, response.status === 408 || response.status === 429 || response.status >= 500, envelope);
110
+ }
111
+ if (request.cacheKey) {
112
+ await this.options.cache.set(cacheKey, response.headers.get('etag'), envelope);
113
+ }
114
+ this.responseSources.set(envelope, 'network');
115
+ return envelope;
116
+ }
117
+ getResponseSource(response) {
118
+ return this.responseSources.get(response) ?? 'network';
119
+ }
120
+ async readCached(cacheKey) {
121
+ return (await this.options.cache.get(cacheKey))?.value ?? null;
122
+ }
123
+ async seedCache(cacheKey, value) {
124
+ await this.options.cache.set(cacheKey, null, value);
125
+ }
126
+ async refreshAuthentication() {
127
+ await this.refreshTokens();
128
+ }
129
+ async refreshTokens() {
130
+ if (this.refreshPromise) {
131
+ return await this.refreshPromise;
132
+ }
133
+ this.refreshPromise = this.performTokenRefresh();
134
+ try {
135
+ await this.refreshPromise;
136
+ }
137
+ finally {
138
+ this.refreshPromise = null;
139
+ }
140
+ }
141
+ async performTokenRefresh() {
142
+ const refreshToken = await this.options.credentials.get('refresh-token');
143
+ if (!refreshToken) {
144
+ await this.options.credentials.clear();
145
+ throw new ApiResponseError('Authentication has expired', 401, null, false, null);
146
+ }
147
+ const controller = new AbortController();
148
+ const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs);
149
+ let response;
150
+ try {
151
+ response = await this.fetchImplementation(`${this.options.baseUrl}${this.options.refreshPath}`, {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
154
+ body: JSON.stringify({ refreshToken }),
155
+ signal: controller.signal,
156
+ });
157
+ }
158
+ catch (error) {
159
+ throw new BackendUnavailableError('Token refresh failed', error);
160
+ }
161
+ finally {
162
+ clearTimeout(timeout);
163
+ }
164
+ let envelope;
165
+ try {
166
+ envelope = await this.parseEnvelope(response);
167
+ }
168
+ catch (error) {
169
+ if (isDefinitiveRefreshRejection(response.status)) {
170
+ await this.options.credentials.clear();
171
+ }
172
+ throw error;
173
+ }
174
+ if (!response.ok || envelope.status === 'error') {
175
+ if (isDefinitiveRefreshRejection(response.status)) {
176
+ await this.options.credentials.clear();
177
+ throw new ApiResponseError('Authentication has expired', response.status, null, false, envelope);
178
+ }
179
+ throw new ApiResponseError(envelope.errorModel?.text ?? envelope.message ?? 'Token refresh failed', response.status, envelope.errorModel?.code ?? null, response.status === 408 || response.status === 429 || response.status >= 500, envelope);
180
+ }
181
+ if (!isRefreshTokens(envelope.data)) {
182
+ throw new ApiResponseError('Token refresh response is missing credentials', response.status, null, false, envelope);
183
+ }
184
+ await this.options.credentials.set('access-token', envelope.data.accessToken);
185
+ if (envelope.data.refreshToken) {
186
+ await this.options.credentials.set('refresh-token', envelope.data.refreshToken);
187
+ }
188
+ }
189
+ async parseEnvelope(response) {
190
+ let value;
191
+ try {
192
+ value = await response.json();
193
+ }
194
+ catch {
195
+ throw new ApiResponseError(`Backend returned a non-JSON response with status ${response.status}`, response.status, null, response.status >= 500, null);
196
+ }
197
+ if (!isEnvelope(value)) {
198
+ throw new ApiResponseError('Backend response does not match the Engineering Memory envelope', response.status, null, false, null);
199
+ }
200
+ return value;
201
+ }
202
+ cacheKey(method, path, body) {
203
+ return sha256(`${method}\n${path}\n${stableStringify(body ?? null)}`);
204
+ }
205
+ }
206
+ function isDefinitiveRefreshRejection(status) {
207
+ return status === 400 || status === 401 || status === 403;
208
+ }
209
+ function isBackendUnavailableStatus(status) {
210
+ return status === 502 || status === 503 || status === 504;
211
+ }
212
+ function isEnvelope(value) {
213
+ if (value === null || typeof value !== 'object') {
214
+ return false;
215
+ }
216
+ const candidate = value;
217
+ return ((candidate.status === 'success' || candidate.status === 'error') &&
218
+ 'data' in candidate &&
219
+ 'message' in candidate &&
220
+ 'errorModel' in candidate);
221
+ }
222
+ function isRefreshTokens(value) {
223
+ return (value !== null &&
224
+ !Array.isArray(value) &&
225
+ typeof value === 'object' &&
226
+ typeof value.accessToken === 'string' &&
227
+ (value.refreshToken === undefined || typeof value.refreshToken === 'string'));
228
+ }
229
+ //# sourceMappingURL=api-client.js.map