@manyoars/oars 0.5.2 → 0.5.4
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 +1 -1
- package/README.md +4 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +65 -3
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +2 -0
- package/dist/sessions/pool-command.d.ts +69 -3
- package/dist/sessions/pool-command.js +324 -36
- package/dist/sessions/pool-nightly.js +3 -2
- package/dist/sessions/pool-outbox.d.ts +13 -0
- package/dist/sessions/pool-outbox.js +46 -0
- package/dist/sessions/pool-send-lock.d.ts +73 -2
- package/dist/sessions/pool-send-lock.js +118 -18
- package/dist/sessions/vocab-load.d.ts +7 -4
- package/dist/sessions/vocab-load.js +16 -6
- package/package.json +2 -2
|
@@ -5,6 +5,8 @@ export interface LockTimes {
|
|
|
5
5
|
/** How long a run waits for the lock before it gives up. */
|
|
6
6
|
waitMs: number;
|
|
7
7
|
pollMs: number;
|
|
8
|
+
/** How long a run waits behind a live holder before it tells the person it is waiting. */
|
|
9
|
+
noticeMs: number;
|
|
8
10
|
}
|
|
9
11
|
/** What a test replaces: `stat` to read a lock as stale at the moment a waiter looks, `open` to make the create fail the way Windows does, `platform` to pick the platform's rules. */
|
|
10
12
|
export interface LockFs {
|
|
@@ -13,8 +15,77 @@ export interface LockFs {
|
|
|
13
15
|
}>;
|
|
14
16
|
open?: (path: string, flags: 'wx') => Promise<FileHandle>;
|
|
15
17
|
platform?: NodeJS.Platform;
|
|
18
|
+
/** Whether a process with this id is running on this machine. A test answers for a pid it made up. */
|
|
19
|
+
running?: (pid: number) => boolean;
|
|
20
|
+
/** This run's pid and host, recorded in the lock; a test names the holder it plays. */
|
|
21
|
+
self?: {
|
|
22
|
+
pid: number;
|
|
23
|
+
host: string;
|
|
24
|
+
};
|
|
25
|
+
/** The clock and the pause between polls; a test drives both instead of sleeping. */
|
|
26
|
+
clock?: {
|
|
27
|
+
now: () => number;
|
|
28
|
+
sleep: (ms: number) => Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export declare const DEFAULT_TIMES: LockTimes;
|
|
32
|
+
/** Who holds the lock, as the holder wrote it into the file: its run, pid, host, and when it took the lock (epoch ms). */
|
|
33
|
+
export interface Holder {
|
|
34
|
+
run: string;
|
|
35
|
+
pid: number;
|
|
36
|
+
host: string;
|
|
37
|
+
since: number;
|
|
38
|
+
}
|
|
39
|
+
/** What a run that waits tells its caller once, when the wait has lasted `noticeMs`: the holder when the file names one (an empty or older-format file names none), how old the lock is, and how long this run will wait. */
|
|
40
|
+
export interface Waiting {
|
|
41
|
+
holder: Holder | undefined;
|
|
42
|
+
ageMs: number;
|
|
43
|
+
waitMs: number;
|
|
44
|
+
}
|
|
45
|
+
/** What a run that gave up knows: the holder, the lock's age now, the time already waited, and the constants the takeover rules use. */
|
|
46
|
+
export interface BusyDetail {
|
|
47
|
+
holder: Holder | undefined;
|
|
48
|
+
/** Time since the holder took the lock, else since the lock file was last touched. */
|
|
49
|
+
ageMs: number;
|
|
50
|
+
/** Time since the lock file was last touched: a live holder touches it every `staleMs / 4`. */
|
|
51
|
+
untouchedMs: number;
|
|
52
|
+
waitedMs: number;
|
|
53
|
+
staleMs: number;
|
|
54
|
+
/** Whether the holder is a process on this host: its heartbeat then keeps the lock fresh while it lives, so only its end (not the clock) frees the lock. */
|
|
55
|
+
local: boolean;
|
|
56
|
+
}
|
|
57
|
+
export interface LockWait {
|
|
58
|
+
/** Called once if the lock is still held after `noticeMs`. */
|
|
59
|
+
onWait?: (waiting: Waiting) => void;
|
|
60
|
+
/** Ends the wait: the run throws the signal's reason. An interrupted `--send` leaves nothing waiting. */
|
|
61
|
+
signal?: AbortSignal;
|
|
16
62
|
}
|
|
17
63
|
export declare class SendLockBusy extends Error {
|
|
18
|
-
|
|
64
|
+
readonly detail: BusyDetail;
|
|
65
|
+
constructor(detail?: BusyDetail);
|
|
66
|
+
}
|
|
67
|
+
export declare const withSendLock: <T>(outbox: string, run: () => Promise<T>, times?: Partial<LockTimes>, fs?: LockFs, wait?: LockWait) => Promise<T>;
|
|
68
|
+
/** Why an interactive send ended before it finished: a signal reached it, or the process that launched it is gone. */
|
|
69
|
+
export declare class SendInterrupted extends Error {
|
|
70
|
+
readonly kind: 'signal' | 'launcher';
|
|
71
|
+
readonly signal?: NodeJS.Signals | undefined;
|
|
72
|
+
constructor(kind: 'signal' | 'launcher', message: string, signal?: NodeJS.Signals | undefined);
|
|
73
|
+
}
|
|
74
|
+
/** What a test replaces: the process's signal hooks, its launcher, and how that launcher's liveness is read. */
|
|
75
|
+
export interface InterruptHost {
|
|
76
|
+
on: (event: NodeJS.Signals, listener: () => void) => unknown;
|
|
77
|
+
off: (event: NodeJS.Signals, listener: () => void) => unknown;
|
|
78
|
+
/** The pid of the process that started this one; checked every `checkMs`. */
|
|
79
|
+
parent: number;
|
|
80
|
+
running: (pid: number) => boolean;
|
|
81
|
+
checkMs: number;
|
|
19
82
|
}
|
|
20
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The signal an interactive `oars pool deposit --send` waits and sends under. Ctrl-C, a closed terminal, and a kill all end it. A launcher that is killed without a signal reaching its child (Windows `taskkill` of the
|
|
85
|
+
* shell or the `oars.cmd` shim, a `timeout` wrapper) leaves the child running, so the launcher's pid is read every second and a run whose launcher is gone ends too. The detached nightly sender is not watched:
|
|
86
|
+
* it is meant to outlive its starter.
|
|
87
|
+
*/
|
|
88
|
+
export declare const watchInterrupt: (host?: InterruptHost) => {
|
|
89
|
+
signal: AbortSignal;
|
|
90
|
+
stop: () => void;
|
|
91
|
+
};
|
|
@@ -1,15 +1,46 @@
|
|
|
1
1
|
// One sender at a time per outbox: the detached sender (hook-bin.js nightly) and `deposit --send` both take this lock, so a batch is never posted twice by two runs that overlap. The lock is a file created
|
|
2
|
-
// with `wx` beside `sent.log`, so it holds across processes as well as inside one. A run that finds it held waits; a lock
|
|
2
|
+
// with `wx` beside `sent.log`, so it holds across processes as well as inside one. A run that finds it held waits; a holder on this host whose pid is not running is taken over at once, and a lock untouched for `staleMs` belongs to a run that died (the rule for a holder whose liveness cannot be read: an empty or older-format file, another host's pid). Either takeover is by one waiter only, under a guard file. The holder touches the file while it runs, so a long send (a first night of raw parts) is never mistaken for a dead run, and it removes the file only if the file still names its own run.
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { mkdir, open, readFile, rm, stat, utimes } from 'node:fs/promises';
|
|
5
|
+
import { hostname } from 'node:os';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
7
|
-
const DEFAULT_TIMES = { staleMs: 10 * 60 * 1000, waitMs: 2 * 60 * 1000, pollMs: 50 };
|
|
8
|
+
export const DEFAULT_TIMES = { staleMs: 10 * 60 * 1000, waitMs: 2 * 60 * 1000, pollMs: 50, noticeMs: 500 };
|
|
8
9
|
export class SendLockBusy extends Error {
|
|
9
|
-
|
|
10
|
+
detail;
|
|
11
|
+
constructor(detail = { holder: undefined, ageMs: 0, untouchedMs: 0, waitedMs: 0, staleMs: DEFAULT_TIMES.staleMs, local: false }) {
|
|
10
12
|
super('Another oars run is sending from this outbox.');
|
|
13
|
+
this.detail = detail;
|
|
11
14
|
}
|
|
12
15
|
}
|
|
16
|
+
const running = (pid) => {
|
|
17
|
+
try {
|
|
18
|
+
process.kill(pid, 0);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
// EPERM: the process exists but belongs to someone else.
|
|
23
|
+
return error instanceof Error && Reflect.get(error, 'code') === 'EPERM';
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/** The holder a lock file names, or `undefined` for a file that is empty, half written, or not in this format: such a lock is judged by the clock alone. */
|
|
27
|
+
const holderOf = async (file) => {
|
|
28
|
+
const text = await readFile(file, 'utf8').catch(() => '');
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(text);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
37
|
+
return undefined;
|
|
38
|
+
const run = Reflect.get(parsed, 'run');
|
|
39
|
+
const pid = Reflect.get(parsed, 'pid');
|
|
40
|
+
const host = Reflect.get(parsed, 'host');
|
|
41
|
+
const since = Reflect.get(parsed, 'since');
|
|
42
|
+
return typeof run === 'string' && typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && typeof host === 'string' && typeof since === 'number' ? { run, pid, host, since } : undefined;
|
|
43
|
+
};
|
|
13
44
|
// EEXIST means another run holds the file. On Windows an open of a file whose delete is still pending (the holder just released it) answers EPERM or EACCES instead: the same contention, so retry within waitMs.
|
|
14
45
|
const contended = (error, platform) => {
|
|
15
46
|
if (!(error instanceof Error))
|
|
@@ -18,24 +49,37 @@ const contended = (error, platform) => {
|
|
|
18
49
|
return code === 'EEXIST' || (platform === 'win32' && (code === 'EPERM' || code === 'EACCES'));
|
|
19
50
|
};
|
|
20
51
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
52
|
+
* Whether a lock can be removed now: its holder is a process on this machine that is not running (taken over at once, whatever the clock says), or the file has not been touched for `staleMs` (the holder's heartbeat stopped).
|
|
53
|
+
* A holder whose liveness cannot be read (an empty file, another host's pid) is judged by the clock alone, which is the only reason `staleMs` stays at ten minutes. A pid the system has handed to another process since reads as
|
|
54
|
+
* running, so pid reuse can only make a waiter wait for the clock, never take a live holder's lock; the lock's age is not taken from the pid for that reason.
|
|
55
|
+
*/
|
|
56
|
+
const reclaimable = async (file, staleMs, fs, now) => {
|
|
57
|
+
const held = await fs.stat(file).catch(() => undefined);
|
|
58
|
+
if (held === undefined)
|
|
59
|
+
return false;
|
|
60
|
+
if (now() - held.mtimeMs > staleMs)
|
|
61
|
+
return true;
|
|
62
|
+
const holder = await holderOf(file);
|
|
63
|
+
return holder !== undefined && holder.host === (fs.self?.host ?? hostname()) && !(fs.running ?? running)(holder.pid);
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* A reclaimable lock is removed under a takeover guard (`send.lock.takeover`, created with `wx`), so one waiter at a time reads the lock again and removes it only if it is still reclaimable. A live lock is never moved
|
|
67
|
+
* or removed: a waiter that read a lock as reclaimable, but meets a lock another run took over and refreshed in the meantime, finds it live under the guard and leaves it. A guard left by a taker that died is
|
|
23
68
|
* removed once it is older than `staleMs`.
|
|
24
69
|
*/
|
|
25
|
-
const takeOver = async (file, staleMs, fs) => {
|
|
70
|
+
const takeOver = async (file, staleMs, fs, now) => {
|
|
26
71
|
const guard = `${file}.takeover`;
|
|
27
72
|
try {
|
|
28
73
|
await (await open(guard, 'wx')).close();
|
|
29
74
|
}
|
|
30
75
|
catch {
|
|
31
76
|
const left = await stat(guard).catch(() => undefined);
|
|
32
|
-
if (left !== undefined &&
|
|
77
|
+
if (left !== undefined && now() - left.mtimeMs > staleMs)
|
|
33
78
|
await rm(guard, { force: true });
|
|
34
79
|
return false;
|
|
35
80
|
}
|
|
36
81
|
try {
|
|
37
|
-
|
|
38
|
-
if (held === undefined || Date.now() - held.mtimeMs <= staleMs)
|
|
82
|
+
if (!(await reclaimable(file, staleMs, fs, now)))
|
|
39
83
|
return false;
|
|
40
84
|
await rm(file, { force: true });
|
|
41
85
|
return true;
|
|
@@ -44,18 +88,25 @@ const takeOver = async (file, staleMs, fs) => {
|
|
|
44
88
|
await rm(guard, { force: true });
|
|
45
89
|
}
|
|
46
90
|
};
|
|
47
|
-
export const withSendLock = async (outbox, run, times = {}, fs = { stat }) => {
|
|
48
|
-
const { staleMs, waitMs, pollMs } = { ...DEFAULT_TIMES, ...times };
|
|
91
|
+
export const withSendLock = async (outbox, run, times = {}, fs = { stat }, wait = {}) => {
|
|
92
|
+
const { staleMs, waitMs, pollMs, noticeMs } = { ...DEFAULT_TIMES, ...times };
|
|
49
93
|
const file = join(outbox, 'send.lock');
|
|
50
94
|
const runId = randomUUID();
|
|
51
95
|
const create = fs.open ?? open;
|
|
52
96
|
const platform = fs.platform ?? process.platform;
|
|
97
|
+
const clock = fs.clock ?? { now: () => Date.now(), sleep: (ms) => sleep(ms) };
|
|
98
|
+
const self = fs.self ?? { pid: process.pid, host: hostname() };
|
|
53
99
|
await mkdir(outbox, { recursive: true });
|
|
54
|
-
const
|
|
100
|
+
const began = clock.now();
|
|
101
|
+
const giveUp = began + waitMs;
|
|
102
|
+
let noticed = false;
|
|
55
103
|
for (;;) {
|
|
104
|
+
if (wait.signal?.aborted === true)
|
|
105
|
+
throw wait.signal.reason;
|
|
56
106
|
try {
|
|
57
107
|
const handle = await create(file, 'wx');
|
|
58
|
-
|
|
108
|
+
const holder = { run: runId, pid: self.pid, host: self.host, since: clock.now() };
|
|
109
|
+
await handle.writeFile(JSON.stringify(holder));
|
|
59
110
|
await handle.close();
|
|
60
111
|
break;
|
|
61
112
|
}
|
|
@@ -65,11 +116,19 @@ export const withSendLock = async (outbox, run, times = {}, fs = { stat }) => {
|
|
|
65
116
|
}
|
|
66
117
|
const held = await fs.stat(file).catch(() => undefined);
|
|
67
118
|
// A takeover that cleared the lock goes straight back to taking it; any other outcome waits, and gives up once waitMs has passed.
|
|
68
|
-
if (held !== undefined &&
|
|
119
|
+
if (held !== undefined && (await reclaimable(file, staleMs, fs, clock.now)) && (await takeOver(file, staleMs, fs, clock.now)))
|
|
69
120
|
continue;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
121
|
+
const at = clock.now();
|
|
122
|
+
const holder = await holderOf(file);
|
|
123
|
+
const untouchedMs = held === undefined ? 0 : Math.max(0, at - held.mtimeMs);
|
|
124
|
+
const ageMs = holder === undefined ? untouchedMs : Math.max(0, at - holder.since);
|
|
125
|
+
if (!noticed && at - began >= noticeMs) {
|
|
126
|
+
noticed = true;
|
|
127
|
+
wait.onWait?.({ holder, ageMs, waitMs });
|
|
128
|
+
}
|
|
129
|
+
if (at >= giveUp)
|
|
130
|
+
throw new SendLockBusy({ holder, ageMs, untouchedMs, waitedMs: at - began, staleMs, local: holder?.host === self.host });
|
|
131
|
+
await clock.sleep(pollMs);
|
|
73
132
|
}
|
|
74
133
|
const beat = setInterval(() => {
|
|
75
134
|
const now = new Date();
|
|
@@ -81,7 +140,48 @@ export const withSendLock = async (outbox, run, times = {}, fs = { stat }) => {
|
|
|
81
140
|
finally {
|
|
82
141
|
clearInterval(beat);
|
|
83
142
|
// The file is ours only while it still names this run; a lock another run took over is theirs.
|
|
84
|
-
if ((await
|
|
143
|
+
if ((await holderOf(file))?.run === runId)
|
|
85
144
|
await rm(file, { force: true });
|
|
86
145
|
}
|
|
87
146
|
};
|
|
147
|
+
/** Why an interactive send ended before it finished: a signal reached it, or the process that launched it is gone. */
|
|
148
|
+
export class SendInterrupted extends Error {
|
|
149
|
+
kind;
|
|
150
|
+
signal;
|
|
151
|
+
constructor(kind, message, signal) {
|
|
152
|
+
super(message);
|
|
153
|
+
this.kind = kind;
|
|
154
|
+
this.signal = signal;
|
|
155
|
+
this.name = 'SendInterrupted';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
159
|
+
/**
|
|
160
|
+
* The signal an interactive `oars pool deposit --send` waits and sends under. Ctrl-C, a closed terminal, and a kill all end it. A launcher that is killed without a signal reaching its child (Windows `taskkill` of the
|
|
161
|
+
* shell or the `oars.cmd` shim, a `timeout` wrapper) leaves the child running, so the launcher's pid is read every second and a run whose launcher is gone ends too. The detached nightly sender is not watched:
|
|
162
|
+
* it is meant to outlive its starter.
|
|
163
|
+
*/
|
|
164
|
+
export const watchInterrupt = (host = { on: (event, listener) => process.on(event, listener), off: (event, listener) => process.off(event, listener), parent: process.ppid, running, checkMs: 1000 }) => {
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
const listeners = SIGNALS.map((name) => [
|
|
167
|
+
name,
|
|
168
|
+
() => {
|
|
169
|
+
controller.abort(new SendInterrupted('signal', `Interrupted by ${name}.`, name));
|
|
170
|
+
},
|
|
171
|
+
]);
|
|
172
|
+
for (const [name, listener] of listeners)
|
|
173
|
+
host.on(name, listener);
|
|
174
|
+
const timer = setInterval(() => {
|
|
175
|
+
if (!host.running(host.parent))
|
|
176
|
+
controller.abort(new SendInterrupted('launcher', 'The program that started this send is gone.'));
|
|
177
|
+
}, host.checkMs);
|
|
178
|
+
timer.unref();
|
|
179
|
+
return {
|
|
180
|
+
signal: controller.signal,
|
|
181
|
+
stop: () => {
|
|
182
|
+
clearInterval(timer);
|
|
183
|
+
for (const [name, listener] of listeners)
|
|
184
|
+
host.off(name, listener);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
};
|
|
@@ -17,10 +17,13 @@ export interface Vocabulary {
|
|
|
17
17
|
models: ReadonlySet<string>;
|
|
18
18
|
harnessVersions: ReadonlyMap<string, ReadonlySet<string>>;
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* packages/cli/vocab/, from src/sessions/ in a test and from dist/sessions/ once built: the same depth. A function, not a constant: a bundle imported from a
|
|
22
|
+
* `data:` URL (the pool's sample-pack command) has no file location, and building this URL at load time would throw before that caller could pass its own folder.
|
|
23
|
+
*/
|
|
24
|
+
export declare const vocabDir: () => URL;
|
|
22
25
|
export declare const PACKAGE_FILES: readonly ["npm.json", "pypi.json", "crates.json", "rubygems.json", "homebrew.json", "go.json", "maven.json"];
|
|
23
26
|
/** Reads and checks every snapshot under `dir` (default: the shipped folder). */
|
|
24
27
|
export declare const readVocabulary: (dir?: URL) => Vocabulary;
|
|
25
|
-
/** The shipped
|
|
26
|
-
export declare const vocabulary: () => Vocabulary;
|
|
28
|
+
/** The vocabulary of a folder (the shipped one by default), read once per folder on first use: reading 6 MB of JSON is not free, and most commands never project. `dir` is for a caller that cannot locate the folder itself. */
|
|
29
|
+
export declare const vocabulary: (dir?: URL) => Vocabulary;
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
// A snapshot that does not parse is an error and never an empty list: an empty list would quietly project every name as `private`.
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { VOCAB_SNAPSHOT_DATE, VOCAB_VERSION } from './public-vocabulary.js';
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* packages/cli/vocab/, from src/sessions/ in a test and from dist/sessions/ once built: the same depth. A function, not a constant: a bundle imported from a
|
|
8
|
+
* `data:` URL (the pool's sample-pack command) has no file location, and building this URL at load time would throw before that caller could pass its own folder.
|
|
9
|
+
*/
|
|
10
|
+
export const vocabDir = () => new URL('../../vocab/', import.meta.url);
|
|
8
11
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
12
|
const recordAt = (value, where) => {
|
|
10
13
|
if (!isRecord(value))
|
|
@@ -35,7 +38,7 @@ const packageSnapshot = (dir, file) => {
|
|
|
35
38
|
};
|
|
36
39
|
export const PACKAGE_FILES = ['npm.json', 'pypi.json', 'crates.json', 'rubygems.json', 'homebrew.json', 'go.json', 'maven.json'];
|
|
37
40
|
/** Reads and checks every snapshot under `dir` (default: the shipped folder). */
|
|
38
|
-
export const readVocabulary = (dir =
|
|
41
|
+
export const readVocabulary = (dir = vocabDir()) => {
|
|
39
42
|
const manifest = readJson(dir, 'manifest.json');
|
|
40
43
|
const snapshotDate = manifest['snapshotDate'];
|
|
41
44
|
if (snapshotDate !== VOCAB_SNAPSHOT_DATE) {
|
|
@@ -56,6 +59,13 @@ export const readVocabulary = (dir = VOCAB_DIR) => {
|
|
|
56
59
|
harnessVersions: new Map(Object.entries(harnesses).map(([harness, versions]) => [harness, new Set(stringsAt(versions, `harness.${harness}`))])),
|
|
57
60
|
};
|
|
58
61
|
};
|
|
59
|
-
|
|
60
|
-
/** The shipped
|
|
61
|
-
export const vocabulary = (
|
|
62
|
+
const loaded = new Map();
|
|
63
|
+
/** The vocabulary of a folder (the shipped one by default), read once per folder on first use: reading 6 MB of JSON is not free, and most commands never project. `dir` is for a caller that cannot locate the folder itself. */
|
|
64
|
+
export const vocabulary = (dir = vocabDir()) => {
|
|
65
|
+
const held = loaded.get(dir.href);
|
|
66
|
+
if (held !== undefined)
|
|
67
|
+
return held;
|
|
68
|
+
const read = readVocabulary(dir);
|
|
69
|
+
loaded.set(dir.href, read);
|
|
70
|
+
return read;
|
|
71
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manyoars/oars",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"mcpName": "io.github.manyoars/oars",
|
|
5
5
|
"description": "A local, searchable record of your coding agent sessions. Reads Claude Code, Cursor, Codex, Copilot CLI, Gemini CLI, and more, and keeps the record on your machine.",
|
|
6
6
|
"homepage": "https://manyoars.com/",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"test:live": "vitest run --config vitest.live.config.ts"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@manyoars/oars-client": "^0.1.
|
|
52
|
+
"@manyoars/oars-client": "^0.1.3",
|
|
53
53
|
"commander": "^15.0.0",
|
|
54
54
|
"openid-client": "^6.8.8"
|
|
55
55
|
},
|