@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.
- package/dist/gitEditDetector.d.ts +85 -0
- package/dist/gitEditDetector.d.ts.map +1 -1
- package/dist/gitEditDetector.js +305 -3
- package/dist/gitEditDetector.js.map +1 -1
- package/dist/hookRuntime.d.ts +20 -0
- package/dist/hookRuntime.d.ts.map +1 -1
- package/dist/hookRuntime.js +82 -9
- package/dist/hookRuntime.js.map +1 -1
- package/dist/hookTimeouts.d.ts +36 -0
- package/dist/hookTimeouts.d.ts.map +1 -1
- package/dist/hookTimeouts.js +37 -0
- package/dist/hookTimeouts.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/projectIdentityFile.d.ts.map +1 -1
- package/dist/projectIdentityFile.js +8 -2
- package/dist/projectIdentityFile.js.map +1 -1
- package/dist/projectSetupHandoff.d.ts +9 -0
- package/dist/projectSetupHandoff.d.ts.map +1 -1
- package/dist/projectSetupHandoff.js +178 -36
- package/dist/projectSetupHandoff.js.map +1 -1
- package/dist/usageGuide.d.ts +1 -1
- package/dist/usageGuide.d.ts.map +1 -1
- package/dist/usageGuide.js +1 -2
- package/dist/usageGuide.js.map +1 -1
- package/package.json +1 -1
- package/src/gitEditDetector.ts +347 -3
- package/src/hookRuntime.ts +96 -8
- package/src/hookTimeouts.ts +43 -0
- package/src/index.ts +6 -0
- package/src/projectIdentityFile.ts +10 -2
- package/src/projectSetupHandoff.ts +202 -42
- package/src/usageGuide.ts +1 -2
- package/tests/projectIdentity.test.ts +9 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { protectWindowsDirectory } from './runtimeSigners.js';
|
|
2
|
-
import {
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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');
|