@kisev/memomatic 1.0.0-dev.69.gd644a007a7ee → 1.0.0-dev.71.gee4e37a5bb61

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/executor.js CHANGED
@@ -1,11 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { randomBytes } from "node:crypto";
2
6
  export function extractJson(response) {
3
7
  const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(response);
4
- const candidates = [fenced?.[1], response].filter(Boolean);
5
- for (const candidate of candidates) {
8
+ for (const candidate of [fenced?.[1], response]) {
9
+ if (!candidate)
10
+ continue;
6
11
  const start = candidate.indexOf("{");
7
12
  const end = candidate.lastIndexOf("}");
8
- if (start === -1 || end <= start)
13
+ if (start < 0 || end <= start)
9
14
  continue;
10
15
  try {
11
16
  return JSON.parse(candidate.slice(start, end + 1));
@@ -19,60 +24,266 @@ export function extractJson(response) {
19
24
  export class OpenCodeExecutor {
20
25
  options;
21
26
  name = "opencode";
27
+ usage = {
28
+ calls: 0,
29
+ retries: 0,
30
+ inputTokens: 0,
31
+ outputTokens: 0,
32
+ cacheReadTokens: 0,
33
+ cacheWriteTokens: 0,
34
+ usageAvailable: false,
35
+ charactersSent: 0,
36
+ };
37
+ child;
38
+ directory;
39
+ url;
40
+ authorization;
22
41
  constructor(options) {
23
42
  this.options = options;
24
43
  }
44
+ async initialize() {
45
+ if (this.url)
46
+ return;
47
+ if (this.options.url) {
48
+ const url = new URL(this.options.url);
49
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
50
+ throw new Error("invalid OpenCode URL");
51
+ this.url = url.href.replace(/\/$/, "");
52
+ if (process.env.OPENCODE_SERVER_PASSWORD)
53
+ this.authorization = `Basic ${Buffer.from(`${process.env.OPENCODE_SERVER_USERNAME ?? "opencode"}:${process.env.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`;
54
+ return;
55
+ }
56
+ let inherited = {};
57
+ if (process.env.OPENCODE_CONFIG_CONTENT) {
58
+ try {
59
+ const value = JSON.parse(process.env.OPENCODE_CONFIG_CONTENT);
60
+ if (!value || typeof value !== "object" || Array.isArray(value))
61
+ throw new Error();
62
+ inherited = value;
63
+ }
64
+ catch {
65
+ throw new Error("OpenCode inline configuration must be a JSON object");
66
+ }
67
+ }
68
+ this.directory = await mkdtemp(join(tmpdir(), "memomatic-opencode-"));
69
+ const password = randomBytes(24).toString("hex");
70
+ this.authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`;
71
+ this.options.observe?.({
72
+ phase: "opencode.start",
73
+ message: "Starting one isolated OpenCode server for this run",
74
+ });
75
+ const child = spawn(this.options.binary ?? "opencode", ["serve", "--hostname", "127.0.0.1", "--port", "0", "--pure"], {
76
+ cwd: this.directory,
77
+ detached: process.platform !== "win32",
78
+ stdio: ["ignore", "pipe", "pipe"],
79
+ env: {
80
+ ...process.env,
81
+ OPENCODE_SERVER_USERNAME: "opencode",
82
+ OPENCODE_SERVER_PASSWORD: password,
83
+ OPENCODE_DISABLE_PROJECT_CONFIG: "1",
84
+ OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
85
+ OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1",
86
+ OPENCODE_DISABLE_DEFAULT_PLUGINS: "1",
87
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
88
+ ...inherited,
89
+ permission: "deny",
90
+ tools: { "*": false },
91
+ agent: {
92
+ ...inherited.agent,
93
+ memomatic: {
94
+ mode: "primary",
95
+ description: "Bounded memory extraction",
96
+ prompt: "Return only the requested JSON. Do not use tools.",
97
+ permission: "deny",
98
+ tools: { "*": false },
99
+ },
100
+ },
101
+ }),
102
+ },
103
+ });
104
+ this.child = child;
105
+ this.url = await new Promise((resolve, reject) => {
106
+ let output = "";
107
+ const timeout = setTimeout(() => done(new Error("OpenCode startup timed out")), 30000);
108
+ const abort = () => done(this.options.signal?.reason instanceof Error
109
+ ? this.options.signal.reason
110
+ : new Error("interrupted"));
111
+ const failed = (error) => done(error);
112
+ const closed = () => done(new Error("OpenCode exited before becoming ready"));
113
+ const data = (chunk) => {
114
+ output = (output + chunk.toString()).slice(-8192);
115
+ const match = /https?:\/\/127\.0\.0\.1:\d+/.exec(output);
116
+ if (match)
117
+ done(undefined, match[0]);
118
+ };
119
+ const done = (error, url) => {
120
+ clearTimeout(timeout);
121
+ child.stdout?.off("data", data);
122
+ child.stderr?.off("data", data);
123
+ child.off("error", failed);
124
+ child.off("exit", closed);
125
+ this.options.signal?.removeEventListener("abort", abort);
126
+ if (error)
127
+ reject(error);
128
+ else
129
+ resolve(url);
130
+ };
131
+ child.stdout?.on("data", data);
132
+ child.stderr?.on("data", data);
133
+ child.once("error", failed);
134
+ child.once("exit", closed);
135
+ this.options.signal?.addEventListener("abort", abort, { once: true });
136
+ if (this.options.signal?.aborted)
137
+ abort();
138
+ });
139
+ // Drain logs without retaining provider output, credentials or prompts.
140
+ child.stdout?.resume();
141
+ child.stderr?.resume();
142
+ this.options.observe?.({ phase: "opencode.ready", message: "OpenCode server ready" });
143
+ }
144
+ async request(path, body, signal) {
145
+ const response = await fetch(`${this.url}${path}`, {
146
+ method: "POST",
147
+ headers: {
148
+ "content-type": "application/json",
149
+ ...(this.authorization ? { authorization: this.authorization } : {}),
150
+ },
151
+ body: JSON.stringify(body),
152
+ signal,
153
+ });
154
+ if (!response.ok)
155
+ throw new Error(`OpenCode ${path.includes("message") ? "model request" : "session request"} failed: HTTP ${response.status}`);
156
+ return (await response.json());
157
+ }
25
158
  async complete(request) {
26
- const args = ["run", "--agent", "build", "--format", "json"];
27
- if (this.options.model)
28
- args.push("--model", this.options.model);
29
- if (this.options.variant)
30
- args.push("--variant", this.options.variant);
31
- args.push("--", `[memomatic-internal] ${request.system}\n\n${request.prompt}`);
32
- for (let attempt = 0; attempt < 2; attempt += 1) {
33
- const output = await this.spawnOnce(args);
159
+ this.options.signal?.throwIfAborted();
160
+ await this.initialize();
161
+ for (let attempt = 0; attempt <= (this.options.retries ?? 1); attempt++) {
162
+ this.options.signal?.throwIfAborted();
163
+ const signal = AbortSignal.any([
164
+ AbortSignal.timeout(this.options.timeoutMs ?? 180000),
165
+ ...(this.options.signal ? [this.options.signal] : []),
166
+ ]);
167
+ let id;
168
+ const started = Date.now();
169
+ const heartbeat = setInterval(() => this.options.observe?.({
170
+ phase: "model.wait",
171
+ message: "Waiting for OpenCode/model response",
172
+ elapsedMs: Date.now() - started,
173
+ attempt: attempt + 1,
174
+ }), 15000);
175
+ heartbeat.unref();
34
176
  try {
35
- const texts = [];
36
- for (const line of output.split("\n")) {
37
- if (!line.trim())
38
- continue;
39
- const event = JSON.parse(line);
40
- if (event.type === "error")
41
- throw new Error("opencode returned an error event");
42
- if (event.type === "text" && typeof event.part?.text === "string")
43
- texts.push(event.part.text);
177
+ const session = await this.request("/session", {
178
+ title: "[memomatic-internal] memory extraction",
179
+ permission: [{ permission: "*", pattern: "*", action: "deny" }],
180
+ }, signal);
181
+ if (typeof session.id !== "string")
182
+ throw new Error("OpenCode session response is malformed");
183
+ id = session.id;
184
+ const slash = this.options.model?.indexOf("/") ?? -1;
185
+ if (this.options.model && slash < 1)
186
+ throw new Error("model must use provider/model format");
187
+ this.usage.calls++;
188
+ this.usage.charactersSent += request.system.length + request.prompt.length;
189
+ this.options.observe?.({
190
+ phase: "model.request",
191
+ message: "Sending prepared fragment to OpenCode",
192
+ characters: request.prompt.length,
193
+ attempt: attempt + 1,
194
+ });
195
+ const response = await this.request(`/session/${encodeURIComponent(id)}/message`, {
196
+ agent: this.options.url ? "build" : "memomatic",
197
+ system: request.system,
198
+ tools: { "*": false },
199
+ ...(this.options.model
200
+ ? {
201
+ model: {
202
+ providerID: this.options.model.slice(0, slash),
203
+ modelID: this.options.model.slice(slash + 1),
204
+ },
205
+ }
206
+ : {}),
207
+ ...(this.options.variant ? { variant: this.options.variant } : {}),
208
+ parts: [{ type: "text", text: `[memomatic-internal] ${request.prompt}` }],
209
+ }, signal);
210
+ const info = response.info;
211
+ if (info?.error)
212
+ throw new Error("OpenCode model returned an error; inspect the configured provider");
213
+ if (info?.tokens) {
214
+ this.usage.usageAvailable = true;
215
+ this.usage.inputTokens += info.tokens.input ?? 0;
216
+ this.usage.outputTokens += info.tokens.output ?? 0;
217
+ this.usage.cacheReadTokens += info.tokens.cache?.read ?? 0;
218
+ this.usage.cacheWriteTokens += info.tokens.cache?.write ?? 0;
44
219
  }
45
- const parsed = extractJson(texts.join("\n"));
220
+ const parts = response.parts;
221
+ if (!Array.isArray(parts))
222
+ throw new Error("OpenCode model response is malformed");
223
+ const parsed = extractJson(parts
224
+ .filter((part) => part.type === "text")
225
+ .map((part) => part.text ?? "")
226
+ .join("\n"));
227
+ this.options.observe?.({
228
+ phase: "model.done",
229
+ message: "Model response received",
230
+ elapsedMs: Date.now() - started,
231
+ ...this.usage,
232
+ });
46
233
  return JSON.stringify(parsed);
47
234
  }
48
- catch {
49
- if (attempt === 1)
50
- throw new Error("opencode run returned no parsable JSON twice");
235
+ catch (error) {
236
+ if (id)
237
+ await this.request(`/session/${encodeURIComponent(id)}/abort`, {}, AbortSignal.timeout(3000)).catch(() => undefined);
238
+ if (this.options.signal?.aborted)
239
+ throw this.options.signal.reason;
240
+ if (attempt === (this.options.retries ?? 1))
241
+ throw new Error(`OpenCode request failed after ${attempt + 1} attempt(s): ${error.message}`);
242
+ this.usage.retries++;
243
+ this.options.observe?.({
244
+ phase: "model.retry",
245
+ message: "Retrying failed model request",
246
+ level: "warn",
247
+ attempt: attempt + 2,
248
+ reason: error.message,
249
+ });
250
+ }
251
+ finally {
252
+ clearInterval(heartbeat);
51
253
  }
52
254
  }
53
255
  throw new Error("unreachable");
54
256
  }
55
- spawnOnce(args) {
56
- return new Promise((resolve, reject) => {
57
- const child = spawn(this.options.binary ?? "opencode", args, {
58
- env: process.env,
59
- stdio: ["ignore", "pipe", "pipe"],
60
- });
61
- let stdout = "";
62
- let stderr = "";
63
- child.stdout.on("data", (chunk) => {
64
- stdout += chunk.toString("utf8");
65
- });
66
- child.stderr.on("data", (chunk) => {
67
- stderr += chunk.toString("utf8");
68
- });
69
- child.on("error", reject);
70
- child.on("close", (code) => {
71
- if (code === 0)
72
- resolve(stdout);
73
- else
74
- reject(new Error(`opencode run exited ${code}: ${stderr.slice(0, 400)}`));
257
+ async close() {
258
+ const child = this.child;
259
+ if (child?.pid && child.exitCode === null && child.signalCode === null) {
260
+ await new Promise((resolve) => {
261
+ const kill = (signal) => {
262
+ try {
263
+ if (process.platform !== "win32" && child.pid)
264
+ process.kill(-child.pid, signal);
265
+ else
266
+ child.kill(signal);
267
+ }
268
+ catch { }
269
+ };
270
+ const timer = setTimeout(() => kill("SIGKILL"), 1000);
271
+ timer.unref();
272
+ child.once("close", () => {
273
+ clearTimeout(timer);
274
+ resolve();
275
+ });
276
+ kill("SIGTERM");
75
277
  });
76
- });
278
+ }
279
+ if (child?.pid && process.platform !== "win32") {
280
+ try {
281
+ process.kill(-child.pid, "SIGKILL");
282
+ }
283
+ catch { }
284
+ }
285
+ this.child = undefined;
286
+ if (this.directory)
287
+ await rm(this.directory, { recursive: true, force: true });
77
288
  }
78
289
  }
@@ -0,0 +1,28 @@
1
+ import { Command, Option, CommanderError, InvalidArgumentError } from "commander";
2
+ export { Command, Option, CommanderError, InvalidArgumentError };
3
+ export declare function integer(value: string): number;
4
+ export declare function duration(value: string): number;
5
+ export declare function program(name: string, description: string, version: string): Command;
6
+ export declare function common(command: Command, prefix: string): Command;
7
+ export declare function configFile(path: string | undefined): Promise<Record<string, unknown>>;
8
+ export declare function configFileSync(path: string | undefined): Record<string, unknown>;
9
+ export declare function applyConfig(command: Command, config: Record<string, unknown>): void;
10
+ export type Event = {
11
+ phase: string;
12
+ message: string;
13
+ level?: "debug" | "info" | "warn" | "error";
14
+ completed?: number;
15
+ total?: number;
16
+ elapsedMs?: number;
17
+ [key: string]: unknown;
18
+ };
19
+ export type Observe = (event: Event) => void;
20
+ export declare function reporter(options?: Record<string, unknown>): {
21
+ emit: Observe;
22
+ close: () => void;
23
+ };
24
+ export declare function cancellation(): {
25
+ signal: AbortSignal;
26
+ dispose: () => void;
27
+ };
28
+ export declare function fail(name: string, error: unknown, json?: boolean): void;
@@ -0,0 +1,181 @@
1
+ import { Command, Option, CommanderError, InvalidArgumentError } from "commander";
2
+ import { readFile } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
4
+ export { Command, Option, CommanderError, InvalidArgumentError };
5
+ export function integer(value) {
6
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)))
7
+ throw new InvalidArgumentError("expected a non-negative integer");
8
+ return Number(value);
9
+ }
10
+ export function duration(value) {
11
+ const match = /^(\d+)(ms|s|m|h)?$/.exec(value);
12
+ if (!match)
13
+ throw new InvalidArgumentError("expected a duration such as 500ms, 30s or 5m");
14
+ const result = Number(match[1]) * ({ ms: 1, s: 1000, m: 60000, h: 3600000 }[match[2] ?? "ms"] ?? 1);
15
+ if (!Number.isSafeInteger(result) || result < 0)
16
+ throw new InvalidArgumentError("duration is out of range");
17
+ return result;
18
+ }
19
+ export function program(name, description, version) {
20
+ return new Command(name)
21
+ .description(description)
22
+ .version(version)
23
+ .exitOverride()
24
+ .configureHelp({ showGlobalOptions: true })
25
+ .showHelpAfterError()
26
+ .configureOutput({ outputError: () => { } });
27
+ }
28
+ export function common(command, prefix) {
29
+ return command
30
+ .addOption(new Option("--config <file>", "JSON configuration file (CLI > env > file > defaults)").env(`${prefix}_CONFIG`))
31
+ .addOption(new Option("--json", "machine-readable result on stdout").env(`${prefix}_JSON`))
32
+ .addOption(new Option("--log-level <level>", "diagnostic verbosity on stderr")
33
+ .choices(["debug", "info", "warn", "error", "silent"])
34
+ .env(`${prefix}_LOG_LEVEL`))
35
+ .addOption(new Option("--log-format <format>", "diagnostic format on stderr")
36
+ .choices(["text", "json"])
37
+ .env(`${prefix}_LOG_FORMAT`))
38
+ .addOption(new Option("--progress <mode>", "terminal progress; never written to stdout")
39
+ .choices(["auto", "always", "never"])
40
+ .env(`${prefix}_PROGRESS`))
41
+ .addOption(new Option("--color <mode>", "color policy (NO_COLOR disables color)")
42
+ .choices(["auto", "always", "never"])
43
+ .env(`${prefix}_COLOR`));
44
+ }
45
+ export async function configFile(path) {
46
+ if (!path)
47
+ return {};
48
+ const value = JSON.parse(await readFile(path, "utf8"));
49
+ if (!value || typeof value !== "object" || Array.isArray(value))
50
+ throw new InvalidArgumentError("configuration must be a JSON object");
51
+ return value;
52
+ }
53
+ export function configFileSync(path) {
54
+ if (!path)
55
+ return {};
56
+ const value = JSON.parse(readFileSync(path, "utf8"));
57
+ if (!value || typeof value !== "object" || Array.isArray(value))
58
+ throw new InvalidArgumentError("configuration must be a JSON object");
59
+ return value;
60
+ }
61
+ export function applyConfig(command, config) {
62
+ for (const option of command.options) {
63
+ const key = option.attributeName();
64
+ if (!(key in config) || ["cli", "env"].includes(command.getOptionValueSource(key) ?? ""))
65
+ continue;
66
+ if (["yes", "config"].includes(key))
67
+ continue;
68
+ const value = config[key];
69
+ if (option.required || option.optional) {
70
+ const raw = String(value);
71
+ if (option.argChoices && !option.argChoices.includes(raw))
72
+ throw new InvalidArgumentError(`invalid configuration value for ${key}`);
73
+ command.setOptionValueWithSource(key, option.parseArg ? option.parseArg(raw, undefined) : raw, "config");
74
+ }
75
+ else {
76
+ if (typeof value !== "boolean")
77
+ throw new InvalidArgumentError(`${key} must be boolean`);
78
+ command.setOptionValueWithSource(key, value, "config");
79
+ }
80
+ }
81
+ }
82
+ export function reporter(options = {}) {
83
+ const started = Date.now();
84
+ const level = String(options.logLevel ?? "info");
85
+ const levels = ["debug", "info", "warn", "error", "silent"];
86
+ const json = options.logFormat === "json";
87
+ const interactive = !json &&
88
+ options.progress !== "never" &&
89
+ (options.progress === "always" || process.stderr.isTTY);
90
+ let current;
91
+ let painted = false;
92
+ let frame = 0;
93
+ let meter;
94
+ const bar = () => {
95
+ if (!meter || meter.total <= 0)
96
+ return "";
97
+ const ratio = Math.min(1, Math.max(0, meter.completed / meter.total));
98
+ const filled = Math.floor(ratio * 16);
99
+ return ` | ${meter.phase} [${"█".repeat(filled)}${"░".repeat(16 - filled)}] ${Math.floor(ratio * 100)}%`;
100
+ };
101
+ const clean = (text) => Array.from(text, (char) => {
102
+ const code = char.codePointAt(0);
103
+ return code < 32 || (code >= 127 && code <= 159) ? " " : char;
104
+ }).join("");
105
+ const line = (event) => `${event.phase}: ${event.message}${event.total !== undefined ? ` [${event.completed ?? 0}/${event.total}]` : ""} (${Math.floor((Date.now() - started) / 1000)}s)`;
106
+ const clear = () => {
107
+ if (painted) {
108
+ process.stderr.write("\r\x1b[2K");
109
+ painted = false;
110
+ }
111
+ };
112
+ const timer = interactive && level !== "silent"
113
+ ? setInterval(() => {
114
+ if (current) {
115
+ const color = process.env.NO_COLOR === undefined &&
116
+ (options.color === "always" || (options.color !== "never" && process.stderr.isTTY));
117
+ process.stderr.write(`\r\x1b[2K${color ? "\x1b[36m" : ""}${["⠋", "⠙", "⠹", "⠸"][frame++ % 4]} ${clean(line(current))}${bar()}${color ? "\x1b[0m" : ""}`);
118
+ painted = true;
119
+ }
120
+ }, 120)
121
+ : undefined;
122
+ timer?.unref();
123
+ const emit = (event) => {
124
+ const severity = event.level ?? "info";
125
+ if (levels.indexOf(severity) < levels.indexOf(level))
126
+ return;
127
+ current = event;
128
+ if (event.total !== undefined)
129
+ meter =
130
+ event.total > 0
131
+ ? {
132
+ phase: event.phase.split(".")[0],
133
+ completed: event.completed ?? 0,
134
+ total: event.total,
135
+ }
136
+ : undefined;
137
+ else if (meter && meter.completed >= meter.total)
138
+ meter = undefined;
139
+ clear();
140
+ process.stderr.write(json
141
+ ? `${JSON.stringify({ at: new Date().toISOString(), elapsedMs: Date.now() - started, ...event })}\n`
142
+ : `${clean(line(event))}\n`);
143
+ };
144
+ return {
145
+ emit,
146
+ close: () => {
147
+ if (timer)
148
+ clearInterval(timer);
149
+ clear();
150
+ },
151
+ };
152
+ }
153
+ export function cancellation() {
154
+ const controller = new AbortController();
155
+ const interrupt = () => controller.abort(new Error("interrupted"));
156
+ process.on("SIGINT", interrupt);
157
+ process.on("SIGTERM", interrupt);
158
+ process.on("SIGHUP", interrupt);
159
+ return {
160
+ signal: controller.signal,
161
+ dispose: () => {
162
+ process.off("SIGINT", interrupt);
163
+ process.off("SIGTERM", interrupt);
164
+ process.off("SIGHUP", interrupt);
165
+ },
166
+ };
167
+ }
168
+ export function fail(name, error, json = false) {
169
+ if (error instanceof CommanderError && error.exitCode === 0)
170
+ return;
171
+ const message = error instanceof Error ? error.message : String(error);
172
+ const interrupted = /interrupted|aborted/i.test(message);
173
+ process.stderr.write(json ? `${JSON.stringify({ status: "error", message })}\n` : `${name}: ${message}\n`);
174
+ process.exitCode = /timeout|timed out|deadline/i.test(message)
175
+ ? 124
176
+ : interrupted
177
+ ? 130
178
+ : error instanceof CommanderError
179
+ ? 2
180
+ : 1;
181
+ }
package/dist/inbox.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type OperationOptions } from "./operations.js";
1
2
  import type { MemomaticContext } from "./service.js";
2
3
  import type { MemomaticPaths } from "./paths.js";
3
4
  export type InboxReport = {
@@ -22,8 +23,9 @@ export declare function dropToInbox(paths: MemomaticPaths, lines: string[], sour
22
23
  * deduplicate exact texts, supersede entries sharing a key, route user-origin
23
24
  * targets, then rebuild the index with embeddings. No model turns.
24
25
  */
25
- export declare function processInbox(context: MemomaticContext, options?: {
26
+ export declare function processInbox(context: MemomaticContext, options?: OperationOptions & {
26
27
  dryRun?: boolean;
28
+ deferIndex?: boolean;
27
29
  }): Promise<InboxReport>;
28
30
  /**
29
31
  * Serialize whole runs (dream or process) with a stale-tolerant lock file so a
package/dist/inbox.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, open, readdir, rename, rm, unlink } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
+ import { check } from "./operations.js";
4
5
  import { writeAtomic } from "@kisev/safe-fs";
5
6
  import { appendDailyEntry, readTextIfExists, replaceEntryLine, writeCorpusFile } from "./corpus.js";
6
7
  import { entryLine, parseEntryLine } from "./entries.js";
@@ -87,6 +88,13 @@ export async function processInbox(context, options = {}) {
87
88
  return report;
88
89
  const corpus = await scanCorpus(context.paths);
89
90
  for (const name of names) {
91
+ check(options);
92
+ options.observe?.({
93
+ phase: "inbox",
94
+ message: "Processing inbox file",
95
+ completed: report.filesProcessed + report.filesRejected,
96
+ total: names.length,
97
+ });
90
98
  const markdown = (await readTextIfExists(join(context.paths.inboxDir, name))) ?? "";
91
99
  const rawLines = markdown.split("\n");
92
100
  const parsedLines = rawLines.filter((line) => line.trim()).map((line) => parseEntryLine(line));
@@ -108,6 +116,7 @@ export async function processInbox(context, options = {}) {
108
116
  }
109
117
  let accepted = false;
110
118
  for (const parsed of allowed) {
119
+ check(options);
111
120
  if (corpus.texts.has(parsed.text)) {
112
121
  report.entriesDuplicated += 1;
113
122
  continue;
@@ -151,8 +160,8 @@ export async function processInbox(context, options = {}) {
151
160
  report.filesRejected += 1;
152
161
  }
153
162
  }
154
- if (!dryRun)
155
- await reindex(context.paths, context.settings, context.store);
163
+ if (!dryRun && !options.deferIndex)
164
+ await reindex(context.paths, context.settings, context.store, options);
156
165
  return report;
157
166
  }
158
167
  /**
@@ -160,11 +169,12 @@ export async function processInbox(context, options = {}) {
160
169
  * nightly sweep and a manual CLI invocation never mutate the corpus together.
161
170
  */
162
171
  export async function withRunLock(paths, work) {
172
+ const owner = `${process.pid} ${new Date().toISOString()} ${randomBytes(12).toString("hex")}\n`;
163
173
  const acquire = async () => {
164
174
  try {
165
175
  const handle = await open(paths.runLockFile, "wx", 0o600);
166
176
  try {
167
- await handle.writeFile(`${process.pid} ${new Date().toISOString()}\n`);
177
+ await handle.writeFile(owner);
168
178
  }
169
179
  finally {
170
180
  await handle.close();
@@ -178,14 +188,24 @@ export async function withRunLock(paths, work) {
178
188
  const handle = await open(paths.runLockFile, "r");
179
189
  try {
180
190
  const info = await handle.stat();
181
- stale = Date.now() - info.mtimeMs > LOCK_STALE_MS;
191
+ const owner = Number.parseInt((await handle.readFile("utf8")).split(" ")[0], 10);
192
+ if (Number.isSafeInteger(owner) && owner > 0) {
193
+ try {
194
+ process.kill(owner, 0);
195
+ }
196
+ catch (cause) {
197
+ stale = cause.code === "ESRCH";
198
+ }
199
+ }
200
+ else
201
+ stale = Date.now() - info.mtimeMs > LOCK_STALE_MS;
182
202
  }
183
203
  finally {
184
204
  await handle.close();
185
205
  }
186
206
  }
187
207
  catch {
188
- return;
208
+ return acquire();
189
209
  }
190
210
  if (!stale)
191
211
  throw new Error("another memomatic run is active");
@@ -198,6 +218,7 @@ export async function withRunLock(paths, work) {
198
218
  return await work();
199
219
  }
200
220
  finally {
201
- await rm(paths.runLockFile, { force: true }).catch(() => undefined);
221
+ if ((await readTextIfExists(paths.runLockFile)) === owner)
222
+ await rm(paths.runLockFile, { force: true }).catch(() => undefined);
202
223
  }
203
224
  }
package/dist/ingest.d.ts CHANGED
@@ -1,16 +1 @@
1
- export type IngestSession = {
2
- id: string;
3
- title: string;
4
- directory: string;
5
- timeCreated: number;
6
- messages: Array<{
7
- role: string;
8
- text: string;
9
- }>;
10
- };
11
1
  export declare function opencodeDatabasePath(): string;
12
- export declare function loadRecentSessions(databaseFile: string, afterTimeCreated: number, options?: {
13
- maxSessions: number;
14
- maxCharsPerSession: number;
15
- before?: number;
16
- }): IngestSession[];