@mnemonik/shared 7.112.1 → 7.112.2

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.
@@ -1,9 +1,11 @@
1
1
  import { protectWindowsDirectory } from './runtimeSigners.js';
2
- import { execFile } from 'node:child_process';
2
+ import { spawn } from 'node:child_process';
3
3
  import { constants } from 'node:fs';
4
4
  import { lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises';
5
5
  import { tmpdir } from 'node:os';
6
- import { dirname, join, relative } from 'node:path';
6
+ import { basename, dirname, join, relative } from 'node:path';
7
+ import { performance } from 'node:perf_hooks';
8
+ import { hookDeadlineMs } from './hookTimeouts.js';
7
9
  import { randomUUID } from 'node:crypto';
8
10
  import { RuntimeReader, hash } from './runtimeReader.js';
9
11
 
@@ -107,6 +109,186 @@ async function writeDiagnostic(path: string, value: ProjectSetupDiagnostic): Pro
107
109
  }
108
110
  }
109
111
 
112
+ /** What the agent is told while `project ensure` is still running detached. */
113
+ export const PROJECT_SETUP_RUNNING =
114
+ 'Mnemonik project setup is still running; call session_bootstrap again in a few seconds.';
115
+
116
+ /** The CLI helper's own bound, enforced by the detached supervisor below. */
117
+ export const PROJECT_ENSURE_TIMEOUT_MS = 30_000;
118
+ const RUNNING = Symbol('running');
119
+
120
+ /**
121
+ * The detached supervisor: runs the verified CLI bin (argv[2], args after it)
122
+ * with the request on stdin under PROJECT_ENSURE_TIMEOUT_MS, then writes
123
+ * { code, killed, stdout } to argv[1] through a rename, so a reader sees a
124
+ * whole result or none. It outlives the hook process: the host may kill the
125
+ * hook at its timeout (5 s on Claude Code and Grok) while the ensure, which
126
+ * consumes a single-use setup request and writes .mnemonik.json, finishes.
127
+ */
128
+ const SUPERVISOR = `
129
+ const { execFile } = require('node:child_process');
130
+ const { writeFileSync, renameSync } = require('node:fs');
131
+ const [out, bin, ...args] = process.argv.slice(1);
132
+ let input = '';
133
+ process.stdin.setEncoding('utf8');
134
+ process.stdin.on('data', (chunk) => (input += chunk));
135
+ process.stdin.on('end', () => {
136
+ const child = execFile(
137
+ process.execPath,
138
+ [bin, ...args],
139
+ { shell: false, timeout: ${PROJECT_ENSURE_TIMEOUT_MS}, killSignal: 'SIGKILL', maxBuffer: 64 * 1024, env: process.env },
140
+ (error, stdout) => {
141
+ const result = { code: error ? (typeof error.code === 'number' ? error.code : null) : 0, killed: !!(error && error.killed), stdout: String(stdout) };
142
+ writeFileSync(out + '.tmp', JSON.stringify(result), { mode: 0o600 });
143
+ renameSync(out + '.tmp', out);
144
+ }
145
+ );
146
+ child.stdin.on('error', () => {});
147
+ child.stdin.end(input);
148
+ });
149
+ `;
150
+
151
+ interface EnsureRun {
152
+ requestId: string;
153
+ root: string;
154
+ result: string;
155
+ startedAt: number;
156
+ }
157
+
158
+ /** A run older than this is no longer waited for (the helper's bound plus slack). */
159
+ const RUN_STALE_MS = PROJECT_ENSURE_TIMEOUT_MS + 10_000;
160
+
161
+ async function readResult(path: string): Promise<unknown | undefined> {
162
+ let raw: string;
163
+ try {
164
+ raw = await readPrivate(path);
165
+ } catch (error) {
166
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
167
+ throw error;
168
+ }
169
+ const result = JSON.parse(raw) as { code?: unknown; killed?: unknown; stdout?: unknown };
170
+ // Exit 3 is the helper's "action required", with its JSON on stdout.
171
+ if (result.killed || (result.code !== 0 && result.code !== 3)) throw new Error('helper_failed');
172
+ try {
173
+ return JSON.parse(String(result.stdout));
174
+ } catch {
175
+ throw new Error('helper_invalid_json');
176
+ }
177
+ }
178
+
179
+ async function waitForResult(path: string, waitMs: number): Promise<unknown | typeof RUNNING> {
180
+ const deadline = Date.now() + waitMs;
181
+ for (;;) {
182
+ const result = await readResult(path);
183
+ if (result !== undefined) return result;
184
+ if (Date.now() >= deadline) return RUNNING;
185
+ await new Promise((resolve) => setTimeout(resolve, Math.min(50, deadline - Date.now())));
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Run `project ensure` for this setup request, waiting at most `waitMs`.
191
+ *
192
+ * The helper runs detached (under SUPERVISOR) and its result lands in the
193
+ * session's private directory, so a hook the host kills at its timeout loses
194
+ * neither the setup nor its outcome. One run per session: while it is
195
+ * running, a later handoff (the agent calls session_bootstrap again and gets a
196
+ * fresh request id) waits for that run instead of starting another; a
197
+ * finished run whose setup completed answers for the new request too.
198
+ */
199
+ async function runEnsure(options: {
200
+ bin: string;
201
+ root: string;
202
+ requestId: string;
203
+ directory: string;
204
+ waitMs: number;
205
+ }): Promise<unknown | typeof RUNNING> {
206
+ await privateDirectory(dirname(options.directory), options.directory);
207
+ await privateDirectory(options.directory, options.directory);
208
+ const runFile = join(options.directory, 'project-setup-run.json');
209
+ let previous: EnsureRun | undefined;
210
+ try {
211
+ previous = JSON.parse(await readPrivate(runFile)) as EnsureRun;
212
+ } catch {
213
+ previous = undefined;
214
+ }
215
+ if (previous && previous.root === options.root && typeof previous.result === 'string') {
216
+ const result = join(options.directory, basename(previous.result));
217
+ const finished = await readResult(result).catch(() => ({ status: 'failed' }));
218
+ if (finished === undefined && Date.now() - previous.startedAt < RUN_STALE_MS) {
219
+ const outcome = await waitForResult(result, options.waitMs);
220
+ if (outcome !== RUNNING) await rm(runFile, { force: true });
221
+ return outcome;
222
+ }
223
+ await rm(runFile, { force: true });
224
+ await rm(result, { force: true });
225
+ if (record(finished) && finished.status === 'done') return finished;
226
+ }
227
+ const result = join(options.directory, `project-setup-${randomUUID()}.result.json`);
228
+ const run: EnsureRun = {
229
+ requestId: options.requestId,
230
+ root: options.root,
231
+ result: basename(result),
232
+ startedAt: Date.now(),
233
+ };
234
+ await writeFileAtomic(runFile, JSON.stringify(run));
235
+ const child = spawn(
236
+ process.execPath,
237
+ ['-e', SUPERVISOR, result, options.bin, 'project', 'ensure', '--agent', '--json'],
238
+ {
239
+ cwd: options.root,
240
+ shell: false,
241
+ detached: true,
242
+ windowsHide: true,
243
+ stdio: ['pipe', 'ignore', 'ignore'],
244
+ // Runtime injection variables and repository/PATH executables are not inherited.
245
+ env: Object.fromEntries(
246
+ [
247
+ 'HOME',
248
+ 'USERPROFILE',
249
+ 'LOCALAPPDATA',
250
+ 'XDG_STATE_HOME',
251
+ 'MNEMONIK_STATE_DIR',
252
+ 'MNEMONIK_SERVER',
253
+ 'MNEMONIK_API_RESOURCE',
254
+ 'MNEMONIK_OAUTH_ISSUER',
255
+ 'SYSTEMROOT',
256
+ 'TEMP',
257
+ 'TMP',
258
+ 'TMPDIR',
259
+ ].flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]]]))
260
+ ),
261
+ }
262
+ );
263
+ const spawnFailed = new Promise<never>((_, reject) =>
264
+ child.once('error', () => reject(new Error('helper_failed')))
265
+ );
266
+ child.stdin?.on('error', () => {});
267
+ child.stdin?.end(JSON.stringify({ requestId: options.requestId }));
268
+ child.unref();
269
+ const outcome = await Promise.race([waitForResult(result, options.waitMs), spawnFailed]);
270
+ if (outcome !== RUNNING) {
271
+ await rm(runFile, { force: true });
272
+ await rm(result, { force: true });
273
+ }
274
+ return outcome;
275
+ }
276
+
277
+ async function writeFileAtomic(path: string, data: string): Promise<void> {
278
+ const temp = `${path}.${randomUUID()}`;
279
+ try {
280
+ const file = await open(temp, 'wx', 0o600);
281
+ try {
282
+ await file.writeFile(data);
283
+ } finally {
284
+ await file.close();
285
+ }
286
+ await rename(temp, path);
287
+ } finally {
288
+ await rm(temp, { force: true });
289
+ }
290
+ }
291
+
110
292
  export async function handoffProjectSetup(input: {
111
293
  response: unknown;
112
294
  cwd: string | undefined;
@@ -115,6 +297,11 @@ export async function handoffProjectSetup(input: {
115
297
  stateFile: string;
116
298
  stateDir: string;
117
299
  familyId: string | undefined;
300
+ /**
301
+ * How long this hook may wait for `project ensure`; defaults to what the
302
+ * host's hook timeout leaves (hookDeadlineMs less the time since start).
303
+ */
304
+ waitMs?: number;
118
305
  }): Promise<string | undefined> {
119
306
  const response = setupResponse(input.response);
120
307
  if (!response) return;
@@ -154,47 +341,20 @@ export async function handoffProjectSetup(input: {
154
341
  const bin = join(dirname(runtime.entry), 'bin.js');
155
342
  if (!runtime.manifest.files[relative(runtime.directory, bin).replaceAll('\\', '/')])
156
343
  throw new Error('verified_cli_bin_missing');
157
- const result = await new Promise<unknown>((resolve, reject) => {
158
- const child = execFile(
159
- process.execPath,
160
- [bin, 'project', 'ensure', '--agent', '--json'],
161
- {
162
- cwd: root,
163
- shell: false,
164
- timeout: 30_000,
165
- killSignal: 'SIGKILL',
166
- maxBuffer: 64 * 1024,
167
- // Runtime injection variables and repository/PATH executables are not inherited.
168
- env: Object.fromEntries(
169
- [
170
- 'HOME',
171
- 'USERPROFILE',
172
- 'LOCALAPPDATA',
173
- 'XDG_STATE_HOME',
174
- 'MNEMONIK_STATE_DIR',
175
- 'MNEMONIK_SERVER',
176
- 'MNEMONIK_API_RESOURCE',
177
- 'MNEMONIK_OAUTH_ISSUER',
178
- 'SYSTEMROOT',
179
- 'TEMP',
180
- 'TMP',
181
- 'TMPDIR',
182
- ].flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]]]))
183
- ),
184
- },
185
- (error, stdout) => {
186
- if (error && (error.killed || error.code !== 3))
187
- return reject(new Error('helper_failed'));
188
- try {
189
- resolve(JSON.parse(stdout));
190
- } catch {
191
- reject(new Error('helper_invalid_json'));
192
- }
193
- }
194
- );
195
- child.stdin?.on('error', () => {});
196
- child.stdin?.end(JSON.stringify({ requestId }));
344
+ const result = await runEnsure({
345
+ bin,
346
+ root,
347
+ requestId,
348
+ directory: dirname(input.stateFile),
349
+ waitMs: input.waitMs ?? Math.max(0, hookDeadlineMs(input.host) - performance.now()),
197
350
  });
351
+ if (result === RUNNING) {
352
+ // Still running detached (runEnsure); the next session_bootstrap's hook
353
+ // waits for it or collects its result, and never starts a second one.
354
+ return PROJECT_SETUP_RUNNING;
355
+ }
356
+ // The helper may have written this folder's identity.
357
+ (await import('./hookRuntime.js')).clearProjectIdentityCache();
198
358
  if (!record(result)) throw new Error('helper_invalid_json');
199
359
  if (result.status === 'done') {
200
360
  outcome = 'done';
package/src/usageGuide.ts CHANGED
@@ -21,7 +21,7 @@ session_bootstrap -> memory_search -> file_context -> [work] -> memory_add -> me
21
21
 
22
22
  ### Session start
23
23
  - session_bootstrap: loads context, policies, pending tasks (call once, first thing)
24
- - memory_search: search by task domain; set workflowContext (feature_implementation, debugging, exploration, policy_review)
24
+ - memory_search: search by task domain
25
25
  - projects: resolve project IDs if context unclear
26
26
  - policy: review safety rules
27
27
 
@@ -65,7 +65,6 @@ Never tell the user significant work is done without calling memory_add first in
65
65
  ## Memory search tips
66
66
 
67
67
  - Query should include task intent + key entities
68
- - Set workflowContext when you know the phase
69
68
  - Use currentFile to boost file-linked memories
70
69
  - Use filterOnly:true only for narrow filters (no embedding, requires >=1 filter)
71
70
 
@@ -71,6 +71,15 @@ test('only a selected regular JSON object without a project id is treated as una
71
71
  await writeFile(path, text);
72
72
  assert.notEqual((await readIdentityFile(root, { selectedRoot: true })).kind, 'absent');
73
73
  }
74
+ for (const text of [
75
+ '{"schemaVersion":2,"futureIdentity":"proj_future_0001"}',
76
+ '{"schemaVersion":0}',
77
+ '{"schemaVersion":null}',
78
+ '{"schemaVersion":"1"}',
79
+ ]) {
80
+ await writeFile(path, text);
81
+ assert.equal((await readIdentityFile(root, { selectedRoot: true })).kind, 'unknown_version');
82
+ }
74
83
  if (process.platform !== 'win32') {
75
84
  await rm(path);
76
85
  const target = join(root, 'other.json');