@yegor256/jinka 0.0.7 → 0.0.9

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/bin/jinka.js CHANGED
@@ -7,31 +7,34 @@ import { readFile } from 'node:fs/promises';
7
7
  import { resolve, join, dirname, parse } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { Jinka } from '../src/jinka.js';
11
- import { Wishes } from '../src/wishes.js';
12
- import { Agent } from '../src/agent.js';
13
- import { Claude } from '../src/claude.js';
14
- import { Codex } from '../src/codex.js';
15
- import { Genie } from '../src/genie.js';
16
- import { Disk } from '../src/disk.js';
17
- import { Tty } from '../src/tty.js';
18
- import { Log } from '../src/log.js';
19
- import { Status } from '../src/status.js';
20
- import { Quota } from '../src/quota.js';
21
- import { Rollouts } from '../src/rollouts.js';
22
- import { Meters } from '../src/meters.js';
23
- import { Freeze } from '../src/freeze.js';
24
- import { Frozen } from '../src/frozen.js';
25
- import { Limited } from '../src/limited.js';
26
- import { Throttled } from '../src/throttled.js';
27
- import { Keys } from '../src/keys.js';
28
- import { Wheel } from '../src/wheel.js';
29
- import { Legend } from '../src/legend.js';
30
- import { Tokens } from '../src/tokens.js';
31
- import { Crew } from '../src/crew.js';
32
- import { Jobs } from '../src/jobs.js';
33
- import { Summary } from '../src/summary.js';
34
- import { Summarized } from '../src/summarized.js';
10
+ import { jinka } from '../src/jinka.js';
11
+ import { wishes } from '../src/wishes.js';
12
+ import { agent } from '../src/agent.js';
13
+ import { claude } from '../src/claude.js';
14
+ import { codex } from '../src/codex.js';
15
+ import { genie } from '../src/genie.js';
16
+ import { disk } from '../src/disk.js';
17
+ import { tty } from '../src/tty.js';
18
+ import { colorless } from '../src/colorless.js';
19
+ import { log } from '../src/log.js';
20
+ import { status } from '../src/status.js';
21
+ import { quota } from '../src/quota.js';
22
+ import { rollouts } from '../src/rollouts.js';
23
+ import { meters } from '../src/meters.js';
24
+ import { freeze } from '../src/freeze.js';
25
+ import { frozen } from '../src/frozen.js';
26
+ import { limited } from '../src/limited.js';
27
+ import { throttled } from '../src/throttled.js';
28
+ import { keys } from '../src/keys.js';
29
+ import { wheel } from '../src/wheel.js';
30
+ import { legend } from '../src/legend.js';
31
+ import { release } from '../src/release.js';
32
+ import { tokens } from '../src/tokens.js';
33
+ import { crew } from '../src/crew.js';
34
+ import { jobs } from '../src/jobs.js';
35
+ import { summary } from '../src/summary.js';
36
+ import { summarized } from '../src/summarized.js';
37
+ import { semaphore } from '../src/semaphore.js';
35
38
 
36
39
  const root = join(dirname(fileURLToPath(import.meta.url)), '..');
37
40
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
@@ -61,9 +64,10 @@ const program = new Command()
61
64
  .name('jinka')
62
65
  .description(`${pkg.description} (${pkg.version})`)
63
66
  .version(pkg.version)
67
+ .configureHelp({ sortOptions: true })
64
68
  .showHelpAfterError('(add --help for additional information)')
65
69
  .addOption(new Option('--wishes <dir>', 'directory with wishes, one per Markdown file').default(join(homedir(), '.jinka', 'wishes'), '~/.jinka/wishes'))
66
- .option('--threads <n>', `how many wishes run at once, up to ${limit}, which the + and - keys change while running`, bounded, 8)
70
+ .option('--threads <n>', `how many wishes run at once, up to ${limit}, which the + and - keys change while running`, bounded, 6)
67
71
  .addOption(new Option('--archive <dir>', 'directory where finished wishes are moved instead of being deleted').default(join(homedir(), '.jinka', 'archive'), '~/.jinka/archive'))
68
72
  .addOption(new Option('--logs <dir>', 'directory where the output of every agent run is saved').default(join(homedir(), '.jinka', 'logs'), '~/.jinka/logs'))
69
73
  .addOption(new Option('--memory <dir>', 'directory where agents keep what they learn about repositories between runs').default(join(homedir(), '.jinka', 'memory'), '~/.jinka/memory'))
@@ -75,31 +79,45 @@ const program = new Command()
75
79
  .addOption(new Option('--model <name>', 'model passed to the agent').default('', 'agent default'))
76
80
  .addOption(new Option('--effort <level>', 'reasoning effort passed to the agent, such as low, medium, high, xhigh, or max').default('', 'agent default'))
77
81
  .option('--throttle <percent>', 'how much of the agent quota, in percent, must be left in every window before a new job starts', percent, 15)
82
+ .addOption(new Option('--pid <file>', 'file that exists while jinka runs, so that a second jinka refuses to start').default(join(homedir(), '.jinka', 'pid.txt'), '~/.jinka/pid.txt'))
83
+ .option('--no-color', 'paint nothing in the terminal, which a non-empty NO_COLOR environment variable also does')
84
+ .option('--no-tui', 'print only the protocol, without the status bars and the list of threads')
78
85
  .addOption(new Option('--limit <n>', 'how many jobs start before jinka waits for them to finish and quits').argParser(positive).default(Infinity, 'unlimited'))
79
86
  .parse();
80
87
  const values = program.opts();
88
+ const s = semaphore(resolve(values.pid));
89
+ await s.acquire().catch((e) => {
90
+ console.error(e.message);
91
+ process.exit(1);
92
+ });
93
+ process.on('exit', () => s.release());
81
94
  const dir = resolve(values.wishes);
82
95
  const engine = values.engine ?? (parse(values.agent).name === 'codex' ? 'codex' : 'claude');
83
- const agent = values.agent === '' ? engine : values.agent;
84
- const tokens = new Tokens();
85
- const quota = new Quota();
86
- const crew = new Crew(values.threads, limit, { warn: (text) => screen.notice().warn(text) });
87
- const jobs = new Jobs(new Genie(new (engine === 'codex' ? Codex : Claude)(new Agent(agent), values.model, values.effort, resolve(values.skill)), new Disk(dir, resolve(values.logs), resolve(values.memory), crew), new Meters([tokens, engine === 'codex' ? new Rollouts(quota, join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'sessions')) : quota])));
88
- const freeze = new Freeze(crew, { warn: (text) => screen.notice().warn(text) }, () => quit(0));
96
+ const a = values.agent === '' ? engine : values.agent;
97
+ const t = tokens();
98
+ const q = quota();
99
+ const c = crew(values.threads, limit, { warn: (text) => screen.notice().warn(text) });
100
+ const j = jobs(genie((engine === 'codex' ? codex : claude)(agent(a), values.model, values.effort, resolve(values.skill)), disk(dir, resolve(values.logs), resolve(values.memory), c), meters([t, engine === 'codex' ? rollouts(q, join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'sessions')) : q])));
101
+ const f = freeze(c, { warn: (text) => screen.notice().warn(text) }, () => quit(0));
102
+ const r = release(pkg.version, 'https://registry.npmjs.org/@yegor256/jinka/latest');
89
103
  const start = Date.now();
90
- const screen = new Summarized(
91
- process.stdout.isTTY
92
- ? new Tty(
93
- process.stdout,
94
- crew,
95
- new Status(start, jobs, agent, values.model, tokens, quota),
96
- new Legend(freeze, pkg.version)
104
+ const tui = process.stdout.isTTY && values.tui;
105
+ const screen = summarized(
106
+ tui
107
+ ? tty(
108
+ values.color && !process.env.NO_COLOR ? process.stdout : colorless(process.stdout),
109
+ c,
110
+ status(start, j, a, values.model, t, q),
111
+ legend(f, r)
97
112
  )
98
- : new Log(process.stdout),
99
- new Summary(start, pkg.version, jobs, { agent, model: values.model, effort: values.effort, wishes: dir }),
113
+ : log(process.stdout),
114
+ summary(start, pkg.version, j, { agent: a, model: values.model, effort: values.effort, wishes: dir }),
100
115
  process.stdout
101
116
  );
102
117
  process.on('exit', () => screen.close());
118
+ if (tui) {
119
+ r.check();
120
+ }
103
121
  const quit = (code) => {
104
122
  screen.close();
105
123
  process.stdout.write('', () => process.stderr.write('', () => process.exit(code)));
@@ -112,18 +130,18 @@ const interrupt = () => {
112
130
  controller.abort();
113
131
  };
114
132
  process.on('SIGINT', interrupt);
115
- new Keys(process.stdin, freeze, crew, interrupt).listen();
116
- new Wheel(process.stdin, screen).listen();
133
+ keys(process.stdin, f, c, interrupt).listen();
134
+ wheel(process.stdin, screen).listen();
117
135
  try {
118
- await new Jinka(
119
- new Limited(new Frozen(new Throttled(new Wishes(dir, values.interval, values.archive === '' ? '' : resolve(values.archive), values.pause, { warn: (text) => screen.notice().warn(text) }), quota, values.throttle, 15 * 60 * 1000, { warn: (text) => screen.notice().warn(text) }), freeze), values.limit, freeze),
120
- jobs,
136
+ await jinka(
137
+ limited(frozen(throttled(wishes(dir, values.interval, values.archive === '' ? '' : resolve(values.archive), values.pause, { warn: (text) => screen.notice().warn(text) }), q, values.throttle, 15 * 60 * 1000, { warn: (text) => screen.notice().warn(text) }), f), values.limit, f),
138
+ j,
121
139
  screen,
122
- crew
140
+ c
123
141
  ).run(controller.signal);
124
142
  quit(130);
125
143
  } catch (e) {
126
144
  screen.close();
127
- console.error(e.message);
145
+ console.error(e);
128
146
  quit(1);
129
147
  }
package/package.json CHANGED
@@ -35,5 +35,5 @@
35
35
  "test": "c8 --check-coverage --lines 80 mocha"
36
36
  },
37
37
  "type": "module",
38
- "version": "0.0.7"
38
+ "version": "0.0.9"
39
39
  }
package/src/agent.js CHANGED
@@ -3,13 +3,10 @@
3
3
 
4
4
  import spawn from 'cross-spawn';
5
5
 
6
- export class Agent {
7
- constructor(command) {
8
- this.command = command;
9
- }
10
- start(args, signal) {
6
+ export const agent = (command) => Object.freeze({
7
+ start: (args, signal) => {
11
8
  const windows = process.platform === 'win32';
12
- const child = spawn(this.command, args, windows ? { stdio: 'pipe' } : { signal, stdio: 'pipe' });
9
+ const child = spawn(command, args, windows ? { stdio: 'pipe' } : { signal, stdio: 'pipe' });
13
10
  if (windows) {
14
11
  signal.addEventListener(
15
12
  'abort',
@@ -19,4 +16,4 @@ export class Agent {
19
16
  }
20
17
  return child;
21
18
  }
22
- }
19
+ });
package/src/archived.js CHANGED
@@ -4,34 +4,26 @@
4
4
  import { copyFile, mkdir, access } from 'node:fs/promises';
5
5
  import { join, dirname, extname } from 'node:path';
6
6
 
7
- export class Archived {
8
- constructor(origin, dir, archive) {
9
- this.origin = origin;
10
- this.dir = dir;
11
- this.archive = archive;
12
- }
13
- name() {
14
- return this.origin.name();
15
- }
16
- async text() {
17
- return this.origin.text();
18
- }
19
- fate() {
20
- return this.origin.fate();
21
- }
22
- async close() {
23
- await copyFile(join(this.dir, this.origin.name()), await this.#target());
24
- await this.origin.close();
25
- }
26
- free() {
27
- this.origin.free();
28
- }
29
- async #target() {
30
- const target = join(this.archive, this.origin.name());
31
- await mkdir(dirname(target), { recursive: true });
32
- return access(target).then(
33
- () => `${target.slice(0, target.length - extname(target).length)}-${Date.now()}${extname(target)}`,
34
- () => target
7
+ export const archived = (origin, dir, archive) => {
8
+ const target = async () => {
9
+ const path = join(archive, origin.name());
10
+ await mkdir(dirname(path), { recursive: true });
11
+ return access(path).then(
12
+ () => `${path.slice(0, path.length - extname(path).length)}-${Date.now()}${extname(path)}`,
13
+ () => path
35
14
  );
36
- }
37
- }
15
+ };
16
+ return Object.freeze({
17
+ name: () => origin.name(),
18
+ text: async () => origin.text(),
19
+ fate: () => origin.fate(),
20
+ close: async () => {
21
+ await access(join(dir, origin.name())).then(
22
+ async () => copyFile(join(dir, origin.name()), await target()),
23
+ () => {}
24
+ );
25
+ await origin.close();
26
+ },
27
+ free: () => origin.free()
28
+ });
29
+ };
package/src/claude.js CHANGED
@@ -1,27 +1,17 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- export class Claude {
5
- constructor(agent, model, effort, plugin) {
6
- this.agent = agent;
7
- this.model = model;
8
- this.effort = effort;
9
- this.plugin = plugin;
10
- }
11
- start(args, signal) {
12
- return this.agent.start(
13
- [
14
- '-p', '--output-format', 'stream-json', '--verbose',
15
- '--plugin-dir', this.plugin,
16
- ...args,
17
- '--dangerously-skip-permissions',
18
- ...(this.model === '' ? [] : ['--model', this.model]),
19
- ...(this.effort === '' ? [] : ['--effort', this.effort])
20
- ],
21
- signal
22
- );
23
- }
24
- lines() {
25
- return [];
26
- }
27
- }
4
+ export const claude = (agent, model, effort, plugin) => Object.freeze({
5
+ start: (args, signal) => agent.start(
6
+ [
7
+ '-p', '--output-format', 'stream-json', '--verbose',
8
+ '--plugin-dir', plugin,
9
+ ...args,
10
+ '--dangerously-skip-permissions',
11
+ ...(model === '' ? [] : ['--model', model]),
12
+ ...(effort === '' ? [] : ['--effort', effort])
13
+ ],
14
+ signal
15
+ ),
16
+ lines: () => []
17
+ });
package/src/codex.js CHANGED
@@ -3,30 +3,20 @@
3
3
 
4
4
  import { join } from 'node:path';
5
5
 
6
- export class Codex {
7
- constructor(agent, model, effort, plugin) {
8
- this.agent = agent;
9
- this.model = model;
10
- this.effort = effort;
11
- this.plugin = plugin;
12
- }
13
- start(args, signal) {
14
- return this.agent.start(
15
- [
16
- 'exec', '--json',
17
- '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check',
18
- ...args,
19
- ...(this.model === '' ? [] : ['--model', this.model]),
20
- ...(this.effort === '' ? [] : ['--config', `model_reasoning_effort=${this.effort}`]),
21
- '-'
22
- ],
23
- signal
24
- );
25
- }
26
- lines() {
27
- return [
28
- `Your skills live in the directory ${join(this.plugin, 'skills')}, one skill per subdirectory, each described in its SKILL.md file.`,
29
- 'Before you start, read the descriptions of these skills, and whenever a skill fits your work, read its SKILL.md file and follow it.'
30
- ];
31
- }
32
- }
6
+ export const codex = (agent, model, effort, plugin) => Object.freeze({
7
+ start: (args, signal) => agent.start(
8
+ [
9
+ 'exec', '--json',
10
+ '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check',
11
+ ...args,
12
+ ...(model === '' ? [] : ['--model', model]),
13
+ ...(effort === '' ? [] : ['--config', `model_reasoning_effort=${effort}`]),
14
+ '-'
15
+ ],
16
+ signal
17
+ ),
18
+ lines: () => [
19
+ `Your skills live in the directory ${join(plugin, 'skills')}, one skill per subdirectory, each described in its SKILL.md file.`,
20
+ 'Before you start, read the descriptions of these skills, and whenever a skill fits your work, read its SKILL.md file and follow it.'
21
+ ]
22
+ });
@@ -0,0 +1,13 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ export const colorless = (stream) => Object.freeze({
5
+ get columns() {
6
+ return stream.columns;
7
+ },
8
+ get rows() {
9
+ return stream.rows;
10
+ },
11
+ on: (event, listener) => stream.on(event, listener),
12
+ write: (text, done) => stream.write(text.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), ''), done)
13
+ });
package/src/crew.js CHANGED
@@ -1,67 +1,62 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- export class Crew {
5
- constructor(size, limit, notice) {
6
- this.limit = limit;
7
- this.notice = notice;
8
- this.seats = new Map(Array.from({ length: size }, (_, i) => [i, { idle: false, controller: new AbortController() }]));
9
- this.state = { launch: () => {}, runs: [] };
10
- }
11
- size() {
12
- return this.seats.size;
13
- }
14
- indexes() {
15
- return [...this.seats.keys()].sort((a, b) => a - b);
16
- }
17
- more() {
18
- if (this.seats.size >= this.limit) {
19
- this.notice.warn(`more than ${this.limit} threads are not allowed`);
20
- return;
21
- }
22
- const index = Array.from({ length: this.limit }, (_, i) => i).find((i) => !this.seats.has(i));
23
- this.seats.set(index, { idle: false, controller: new AbortController() });
24
- this.state.launch(index);
25
- }
26
- less() {
27
- const index = this.indexes().findLast((i) => this.seats.get(i).idle);
28
- if (this.seats.size === 1) {
29
- this.notice.warn('the last thread cannot be removed');
30
- } else if (index === undefined) {
31
- this.notice.warn('no idle thread can be removed right now');
32
- } else {
33
- this.seats.get(index).controller.abort();
34
- this.seats.delete(index);
4
+ export const crew = (size, limit, notice) => {
5
+ const seats = new Map(Array.from({ length: size }, (_, i) => [i, { idle: false, controller: new AbortController() }]));
6
+ const state = { launch: () => {}, runs: [] };
7
+ const indexes = () => [...seats.keys()].sort((a, b) => a - b);
8
+ const join = async (from) => {
9
+ const to = state.runs.length;
10
+ if (from < to) {
11
+ await Promise.all(state.runs.slice(from, to));
12
+ await join(to);
35
13
  }
36
- }
37
- async run(wishes, wand, signal) {
38
- this.state.launch = (index) => {
39
- const seat = this.seats.get(index);
40
- this.state.runs.push(
41
- wand(
42
- index,
43
- {
44
- next: async (s) => {
45
- seat.idle = true;
46
- try {
47
- return await wishes.next(s);
48
- } finally {
49
- seat.idle = false;
14
+ };
15
+ return Object.freeze({
16
+ size: () => seats.size,
17
+ indexes,
18
+ more: () => {
19
+ if (seats.size >= limit) {
20
+ notice.warn(`more than ${limit} threads are not allowed`);
21
+ return;
22
+ }
23
+ const index = Array.from({ length: limit }, (_, i) => i).find((i) => !seats.has(i));
24
+ seats.set(index, { idle: false, controller: new AbortController() });
25
+ state.launch(index);
26
+ },
27
+ less: () => {
28
+ const index = indexes().findLast((i) => seats.get(i).idle);
29
+ if (seats.size === 1) {
30
+ notice.warn('the last thread cannot be removed');
31
+ } else if (index === undefined) {
32
+ notice.warn('no idle thread can be removed right now');
33
+ } else {
34
+ seats.get(index).controller.abort();
35
+ seats.delete(index);
36
+ }
37
+ },
38
+ run: async (wishes, wand, signal) => {
39
+ state.launch = (index) => {
40
+ const seat = seats.get(index);
41
+ state.runs.push(
42
+ wand(
43
+ index,
44
+ {
45
+ next: async (s) => {
46
+ seat.idle = true;
47
+ try {
48
+ return await wishes.next(s);
49
+ } finally {
50
+ seat.idle = false;
51
+ }
50
52
  }
51
- }
52
- },
53
- AbortSignal.any([signal, seat.controller.signal])
54
- )
55
- );
56
- };
57
- this.indexes().forEach((i) => this.state.launch(i));
58
- await this.#join(0);
59
- }
60
- async #join(from) {
61
- const to = this.state.runs.length;
62
- if (from < to) {
63
- await Promise.all(this.state.runs.slice(from, to));
64
- await this.#join(to);
53
+ },
54
+ AbortSignal.any([signal, seat.controller.signal])
55
+ )
56
+ );
57
+ };
58
+ indexes().forEach((i) => state.launch(i));
59
+ await join(0);
65
60
  }
66
- }
67
- }
61
+ });
62
+ };
package/src/disk.js CHANGED
@@ -5,37 +5,29 @@ import { createWriteStream } from 'node:fs';
5
5
  import { mkdir } from 'node:fs/promises';
6
6
  import { join, dirname, extname } from 'node:path';
7
7
 
8
- export class Disk {
9
- constructor(wishes, logs, memory, crew) {
10
- this.wishes = wishes;
11
- this.logs = logs;
12
- this.memory = memory;
13
- this.crew = crew;
14
- }
15
- lines(wish) {
16
- return [
17
- `The wish is written in the file ${join(this.wishes, wish.name())}, ${wish.fate()}.`,
18
- `All wishes live in the directory ${this.wishes}, and you may add new wishes there as Markdown files.`,
19
- `If this wish is an atomic job that involves no other jobs, make it come true yourself;`,
20
- `otherwise, do not do it yourself, but split it into smaller wishes`,
21
- `and write each of them as a separate Markdown file in the directory ${join(this.wishes, dirname(wish.name()))}.`,
22
- `Always start the name of every new wish file with a small English letter, never with a capital one.`,
23
- `In every Git repository you touch, do all your work inside a separate Git worktree, never in the main directory of the repository, so that other wishes running at the same time do not see your changes.`,
24
- `Never spend time waiting while doing a wish, neither sleeping nor polling for a build, a review, a merge, or anything else;`,
25
- `if something must happen first, write a new wish that waits for it and finishes the job, and then complete the current wish right away.`, `Your memory lives in the directory ${this.memory}, keep there what the skills tell you to keep.`,
26
- `Jinka runs ${this.crew.size()} threads right now, so up to ${this.crew.size()} wishes run at once.`
27
- ];
28
- }
29
- args() {
30
- return ['--add-dir', this.wishes, '--add-dir', this.memory];
31
- }
32
- async log(wish) {
8
+ export const disk = (wishes, logs, memory, crew) => Object.freeze({
9
+ lines: (wish) => [
10
+ `The wish is written in the file ${join(wishes, wish.name())}, ${wish.fate()}.`,
11
+ 'Never delete, move, or rename the file of this wish yourself, even after you split it into smaller wishes, since Jinka takes care of it.',
12
+ `All wishes live in the directory ${wishes}, and you may add new wishes there as Markdown files.`,
13
+ `If this wish is an atomic job that involves no other jobs, make it come true yourself;`,
14
+ `otherwise, do not do it yourself, but split it into smaller wishes`,
15
+ `and write each of them as a separate Markdown file in the directory ${join(wishes, dirname(wish.name()))}.`,
16
+ `Always start the name of every new wish file with a small English letter, never with a capital one.`,
17
+ `In every Git repository you touch, do all your work inside a separate Git worktree, never in the main directory of the repository, so that other wishes running at the same time do not see your changes.`,
18
+ `Never spend time waiting while doing a wish, neither sleeping nor polling for a build, a review, a merge, or anything else;`,
19
+ `if something must happen first, write a new wish that waits for it and finishes the job, and then complete the current wish right away.`,
20
+ `Your memory lives in the directory ${memory}, keep there what the skills tell you to keep.`,
21
+ `Jinka runs ${crew.size()} threads right now, so up to ${crew.size()} wishes run at once.`
22
+ ],
23
+ args: () => ['--add-dir', wishes, '--add-dir', memory],
24
+ log: async (wish) => {
33
25
  const file = join(
34
- this.logs,
26
+ logs,
35
27
  `${wish.name().slice(0, wish.name().length - extname(wish.name()).length)}-${new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15)}.jsonl`
36
28
  );
37
29
  await mkdir(dirname(file), { recursive: true });
38
- await mkdir(this.memory, { recursive: true });
30
+ await mkdir(memory, { recursive: true });
39
31
  return createWriteStream(file);
40
32
  }
41
- }
33
+ });
package/src/duration.js CHANGED
@@ -1,31 +1,31 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- export class Duration {
5
- constructor(millis) {
6
- this.millis = millis;
7
- }
8
- minutes() {
9
- const seconds = Math.floor(this.millis / 1000);
4
+ export const duration = (millis) => {
5
+ const minutes = () => {
6
+ const seconds = Math.floor(millis / 1000);
10
7
  return [Math.floor(seconds / 60), seconds % 60].map((p) => String(p).padStart(2, '0')).join(':');
11
- }
12
- clock() {
13
- const minutes = Math.floor(this.millis / 60_000);
14
- if (minutes < 1000) {
15
- return this.minutes().padStart(6);
8
+ };
9
+ return Object.freeze({
10
+ minutes,
11
+ clock: () => {
12
+ const total = Math.floor(millis / 60_000);
13
+ if (total < 1000) {
14
+ return minutes().padStart(6);
15
+ }
16
+ if (total < 6000) {
17
+ return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}m`;
18
+ }
19
+ return `${Math.floor(total / 60)}h`.padStart(6);
20
+ },
21
+ phrase: () => {
22
+ const seconds = Math.floor(millis / 1000);
23
+ const [count, unit] = seconds < 60 ? [seconds, 'second'] : [Math.floor(seconds / 60), 'minute'];
24
+ return `${count} ${unit}${count === 1 ? '' : 's'}`;
25
+ },
26
+ hours: () => {
27
+ const seconds = Math.floor(millis / 1000);
28
+ return [Math.floor(seconds / 3600), Math.floor(seconds / 60) % 60, seconds % 60].map((p) => String(p).padStart(2, '0')).join(':');
16
29
  }
17
- if (minutes < 6000) {
18
- return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}m`;
19
- }
20
- return `${Math.floor(minutes / 60)}h`.padStart(6);
21
- }
22
- phrase() {
23
- const seconds = Math.floor(this.millis / 1000);
24
- const [count, unit] = seconds < 60 ? [seconds, 'second'] : [Math.floor(seconds / 60), 'minute'];
25
- return `${count} ${unit}${count === 1 ? '' : 's'}`;
26
- }
27
- hours() {
28
- const seconds = Math.floor(this.millis / 1000);
29
- return [Math.floor(seconds / 3600), Math.floor(seconds / 60) % 60, seconds % 60].map((p) => String(p).padStart(2, '0')).join(':');
30
- }
31
- }
30
+ });
31
+ };
@@ -0,0 +1,10 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ export const ellipsized = (origin, limit) => Object.freeze({
5
+ text: () => (
6
+ origin.length < limit
7
+ ? origin
8
+ : `${origin.slice(0, Math.floor((limit - 2) / 2))}…${origin.slice(-Math.ceil((limit - 2) / 2))}`
9
+ )
10
+ });