@yegor256/jinka 0.0.2 → 0.0.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/src/archived.js CHANGED
@@ -4,11 +4,6 @@
4
4
  import { copyFile, mkdir, access } from 'node:fs/promises';
5
5
  import { join, dirname, extname } from 'node:path';
6
6
 
7
- /**
8
- * A wish that goes into the archive directory instead of disappearing.
9
- * The archived copy keeps the subdirectory of the wish, and gets a
10
- * timestamp in its name when a file with the same name is already there.
11
- */
12
7
  export class Archived {
13
8
  constructor(origin, dir, archive) {
14
9
  this.origin = origin;
package/src/claude.js CHANGED
@@ -1,25 +1,17 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import { spawn } from 'node:child_process';
5
4
  import { createInterface } from 'node:readline';
6
- import { join } from 'node:path';
7
5
  import { Event } from './event.js';
6
+ import { Tokens } from './tokens.js';
7
+ import { Model } from './model.js';
8
8
 
9
- /**
10
- * The coding agent that makes one wish come true.
11
- * It starts the agent process in headless mode, writes the prompt
12
- * to its standard input, and reports every event of its stream-json
13
- * output to a screen row. The model, when not empty, is passed
14
- * to the agent verbatim. The process gets SIGTERM when the signal
15
- * aborts.
16
- */
17
9
  export class Claude {
18
- constructor(command, plugin, dir, model) {
19
- this.command = command;
10
+ constructor(agent, plugin, disk, meter) {
11
+ this.agent = agent;
20
12
  this.plugin = plugin;
21
- this.dir = dir;
22
- this.model = model;
13
+ this.disk = disk;
14
+ this.meter = meter;
23
15
  }
24
16
  async run(wish, row, signal) {
25
17
  const prompt = [
@@ -27,34 +19,38 @@ export class Claude {
27
19
  '',
28
20
  await wish.text(),
29
21
  '',
30
- `The wish is written in the file ${join(this.dir, wish.name())}, ${wish.fate()}.`,
31
- `All wishes live in the directory ${this.dir}, and you may add new wishes there as Markdown files.`
22
+ ...this.disk.lines(wish),
23
+ 'When you finish, end with a single plain sentence that says what you did.',
24
+ 'On the next line, write the full URL of the GitHub issue or pull request you worked on, if there was one.'
32
25
  ].join('\n');
33
- const agent = spawn(
34
- this.command,
35
- [
36
- '-p', '--output-format', 'stream-json', '--verbose',
37
- '--plugin-dir', this.plugin, '--add-dir', this.dir,
38
- '--dangerously-skip-permissions',
39
- ...(this.model === '' ? [] : ['--model', this.model])
40
- ],
41
- { signal, stdio: ['pipe', 'pipe', 'ignore'] }
42
- );
43
- agent.stdin.on('error', () => {});
44
- agent.stdin.end(prompt);
45
- createInterface({ input: agent.stdout }).on('line', (line) => {
26
+ const log = await this.disk.log(wish);
27
+ const child = this.agent.start(['--plugin-dir', this.plugin, ...this.disk.args()], signal);
28
+ child.stdin.on('error', () => {});
29
+ child.stdin.end(prompt);
30
+ child.stdout.pipe(log, { end: false });
31
+ child.stderr.pipe(log, { end: false });
32
+ let summary = '';
33
+ let link = '';
34
+ const usage = new Tokens();
35
+ const model = new Model();
36
+ createInterface({ input: child.stdout }).on('line', (line) => {
37
+ this.meter.add(line);
38
+ usage.add(line);
39
+ model.add(line);
40
+ summary = new Event(line).summary() || summary;
41
+ link = new Event(line).link() || link;
46
42
  const text = new Event(line).text();
47
43
  if (text !== '') {
48
44
  row.show(text);
49
45
  }
50
46
  });
51
47
  return new Promise((resolve, reject) => {
52
- agent.on('error', (e) => {
48
+ child.on('error', (e) => {
53
49
  if (!signal.aborted) {
54
- reject(new Error(`Cannot start the agent "${this.command}": ${e.message}`));
50
+ reject(new Error(`Cannot start the agent "${child.spawnfile}": ${e.message}`));
55
51
  }
56
52
  });
57
- agent.on('close', (code) => resolve(code ?? 1));
53
+ child.on('close', (code) => log.end(() => resolve({ code: code ?? 1, summary, link, tokens: usage.text(), model: model.text() })));
58
54
  });
59
55
  }
60
56
  }
package/src/crew.js ADDED
@@ -0,0 +1,67 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
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.show(`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.show('the last thread cannot be removed');
30
+ } else if (index === undefined) {
31
+ this.notice.show('no idle thread can be removed right now');
32
+ } else {
33
+ this.seats.get(index).controller.abort();
34
+ this.seats.delete(index);
35
+ }
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;
50
+ }
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);
65
+ }
66
+ }
67
+ }
package/src/disk.js ADDED
@@ -0,0 +1,35 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { createWriteStream } from 'node:fs';
5
+ import { mkdir } from 'node:fs/promises';
6
+ import { join, dirname, extname } from 'node:path';
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
+ `Your memory lives in the directory ${this.memory}, keep there what the skills tell you to keep.`,
20
+ `Jinka runs ${this.crew.size()} threads right now, so up to ${this.crew.size()} wishes run at once.`
21
+ ];
22
+ }
23
+ args() {
24
+ return ['--add-dir', this.wishes, '--add-dir', this.memory];
25
+ }
26
+ async log(wish) {
27
+ const file = join(
28
+ this.logs,
29
+ `${wish.name().slice(0, wish.name().length - extname(wish.name()).length)}-${new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15)}.jsonl`
30
+ );
31
+ await mkdir(dirname(file), { recursive: true });
32
+ await mkdir(this.memory, { recursive: true });
33
+ return createWriteStream(file);
34
+ }
35
+ }
package/src/duration.js CHANGED
@@ -1,11 +1,6 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- /**
5
- * A span of time, printed the way a clock prints it.
6
- * It drops the milliseconds and pads every part with zeros, either as
7
- * minutes and seconds, or as hours, minutes and seconds.
8
- */
9
4
  export class Duration {
10
5
  constructor(millis) {
11
6
  this.millis = millis;
package/src/event.js CHANGED
@@ -1,13 +1,8 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- /**
5
- * One line of the stream-json output of the agent.
6
- * It turns the line into a short phrase that fits into one screen line,
7
- * such as the name of a tool with its main argument, or the first line
8
- * of what the agent said. Lines that carry nothing worth showing
9
- * become an empty phrase.
10
- */
4
+ import { styleText } from 'node:util';
5
+
11
6
  export class Event {
12
7
  constructor(line) {
13
8
  this.line = line;
@@ -19,6 +14,22 @@ export class Event {
19
14
  return this.line.trim();
20
15
  }
21
16
  }
17
+ summary() {
18
+ try {
19
+ const json = JSON.parse(this.line);
20
+ return json.type === 'result' ? this.#first(String(json.result ?? '')) : '';
21
+ } catch {
22
+ return '';
23
+ }
24
+ }
25
+ link() {
26
+ try {
27
+ const json = JSON.parse(this.line);
28
+ return json.type === 'result' ? (String(json.result ?? '').match(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:issues|pull)\/\d+/) ?? [''])[0] : '';
29
+ } catch {
30
+ return '';
31
+ }
32
+ }
22
33
  #phrase(json) {
23
34
  if (json.type === 'assistant') {
24
35
  return (json.message?.content ?? []).map((part) => this.#part(part)).filter((p) => p !== '').join(' ');
@@ -34,13 +45,38 @@ export class Event {
34
45
  }
35
46
  if (part.type === 'tool_use') {
36
47
  const input = part.input ?? {};
37
- return [part.name, input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.query ?? input.skill ?? input.description]
38
- .filter((p) => p !== undefined)
39
- .map((p) => this.#first(String(p)))
40
- .join(': ');
48
+ const phrase = this.#plain(part.name, input);
49
+ if (phrase !== undefined) {
50
+ return this.#first(phrase);
51
+ }
52
+ return styleText(
53
+ 'gray',
54
+ [part.name, input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.query ?? input.skill ?? input.description]
55
+ .filter((p) => p !== undefined)
56
+ .map((p) => this.#first(String(p)))
57
+ .join(': '),
58
+ { validateStream: false }
59
+ );
41
60
  }
42
61
  return '';
43
62
  }
63
+ #plain(name, input) {
64
+ const phrases = {
65
+ Bash: () => input.description,
66
+ Agent: () => input.description,
67
+ Task: () => input.description,
68
+ Read: () => input.file_path && `reading ${input.file_path}`,
69
+ Edit: () => input.file_path && `editing ${input.file_path}`,
70
+ MultiEdit: () => input.file_path && `editing ${input.file_path}`,
71
+ Write: () => input.file_path && `writing ${input.file_path}`,
72
+ Grep: () => input.pattern && `searching for "${input.pattern}"`,
73
+ Glob: () => input.pattern && `looking for files "${input.pattern}"`,
74
+ WebFetch: () => input.url && `fetching ${input.url}`,
75
+ WebSearch: () => input.query && `searching the web for "${input.query}"`,
76
+ Skill: () => input.skill && `using the skill ${input.skill}`
77
+ };
78
+ return Object.hasOwn(phrases, name) ? phrases[name]() || undefined : undefined;
79
+ }
44
80
  #first(text) {
45
81
  return text.split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
46
82
  }
package/src/freeze.js ADDED
@@ -0,0 +1,43 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ export class Freeze {
5
+ constructor(crew, quit) {
6
+ this.crew = crew;
7
+ this.quit = quit;
8
+ this.state = { frozen: false, idle: 0 };
9
+ this.waiters = [];
10
+ }
11
+ on() {
12
+ this.state.frozen = true;
13
+ this.#check();
14
+ }
15
+ off() {
16
+ this.state.frozen = false;
17
+ this.waiters.splice(0).forEach((w) => w());
18
+ }
19
+ active() {
20
+ return this.state.frozen;
21
+ }
22
+ enter() {
23
+ this.state.idle += 1;
24
+ this.#check();
25
+ }
26
+ leave() {
27
+ this.state.idle -= 1;
28
+ }
29
+ async thaw(signal) {
30
+ signal.throwIfAborted();
31
+ if (this.state.frozen) {
32
+ await new Promise((resolve, reject) => {
33
+ this.waiters.push(resolve);
34
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
35
+ });
36
+ }
37
+ }
38
+ #check() {
39
+ if (this.state.frozen && this.state.idle === this.crew.size()) {
40
+ this.quit();
41
+ }
42
+ }
43
+ }
package/src/frozen.js ADDED
@@ -0,0 +1,26 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ export class Frozen {
5
+ constructor(origin, freeze) {
6
+ this.origin = origin;
7
+ this.freeze = freeze;
8
+ }
9
+ async next(signal) {
10
+ this.freeze.enter();
11
+ try {
12
+ return await this.#wish(signal);
13
+ } finally {
14
+ this.freeze.leave();
15
+ }
16
+ }
17
+ async #wish(signal) {
18
+ await this.freeze.thaw(signal);
19
+ const wish = await this.origin.next(signal);
20
+ if (this.freeze.active()) {
21
+ wish.free();
22
+ return this.#wish(signal);
23
+ }
24
+ return wish;
25
+ }
26
+ }
package/src/jinka.js CHANGED
@@ -3,21 +3,18 @@
3
3
 
4
4
  import { Wand } from './wand.js';
5
5
 
6
- /**
7
- * The application, which keeps several wands busy with wishes.
8
- * Each wand gets its own screen row and protocol, and all of them share one
9
- * directory of wishes and one agent, until the signal aborts.
10
- */
11
6
  export class Jinka {
12
- constructor(wishes, agent, screen, threads) {
7
+ constructor(wishes, agent, screen, crew) {
13
8
  this.wishes = wishes;
14
9
  this.agent = agent;
15
10
  this.screen = screen;
16
- this.threads = threads;
11
+ this.crew = crew;
17
12
  }
18
13
  async run(signal) {
19
- await Promise.all(
20
- Array.from({ length: this.threads }, (_, i) => new Wand(this.wishes, this.agent, this.screen.row(i), this.screen.protocol(i)).run(signal))
14
+ await this.crew.run(
15
+ this.wishes,
16
+ (i, wishes, s) => new Wand(wishes, this.agent, this.screen.row(i), this.screen.notice()).run(s),
17
+ signal
21
18
  );
22
19
  }
23
20
  }
package/src/keys.js ADDED
@@ -0,0 +1,28 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ export class Keys {
5
+ constructor(stream, freeze, crew, interrupt) {
6
+ this.stream = stream;
7
+ this.freeze = freeze;
8
+ this.crew = crew;
9
+ this.interrupt = interrupt;
10
+ }
11
+ listen() {
12
+ if (this.stream.isTTY) {
13
+ this.stream.setRawMode(true);
14
+ }
15
+ this.stream.on('data', (chunk) => [...chunk.toString()].forEach((k) => this.#press(k)));
16
+ }
17
+ #press(key) {
18
+ ({
19
+ f: () => this.freeze.on(),
20
+ F: () => this.freeze.on(),
21
+ c: () => this.freeze.off(),
22
+ C: () => this.freeze.off(),
23
+ '+': () => this.crew.more(),
24
+ '-': () => this.crew.less(),
25
+ '\x03': () => this.interrupt()
26
+ })[key]?.();
27
+ }
28
+ }
package/src/legend.js ADDED
@@ -0,0 +1,15 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { styleText } from 'node:util';
5
+
6
+ export class Legend {
7
+ constructor(freeze) {
8
+ this.freeze = freeze;
9
+ }
10
+ text() {
11
+ return this.freeze.active()
12
+ ? `${styleText('bgGray', 'C', { validateStream: false })} - to continue`
13
+ : `${styleText('bgGray', 'F', { validateStream: false })} - to finish and quit, ${styleText('bgGray', '+/-', { validateStream: false })} to add threads`;
14
+ }
15
+ }
package/src/log.js CHANGED
@@ -1,25 +1,18 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { stripVTControlCharacters } from 'node:util';
4
5
  import { Row } from './row.js';
5
6
 
6
- /**
7
- * The screen for anything that is not a terminal, such as CI or a pipe.
8
- * It prints every change of every row and every protocol line
9
- * as a new plain line, without any cursor movement.
10
- */
11
7
  export class Log {
12
8
  constructor(stream) {
13
9
  this.stream = stream;
14
10
  }
15
11
  row(index) {
16
- return new Row({ push: ([i, text]) => this.stream.write(`[${i + 1}] ${text.replace('\t', ' ')}\n`) }, index);
12
+ return new Row({ push: ([i, text]) => this.stream.write(`[${i + 1}] ${stripVTControlCharacters(text).replace('\t', ' ')}\n`) }, index);
17
13
  }
18
- protocol(index) {
19
- return new Row(
20
- { push: ([i, text]) => this.stream.write(`${new Date().toTimeString().slice(0, 8)} [${i + 1}] ${text.replace('\t', ' ')}\n`) },
21
- index
22
- );
14
+ notice() {
15
+ return new Row({ push: ([, text]) => this.stream.write(`${new Date().toTimeString().slice(0, 8)} ${text}\n`) }, 0);
23
16
  }
24
17
  close() {}
25
18
  }
package/src/meters.js ADDED
@@ -0,0 +1,11 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ export class Meters {
5
+ constructor(meters) {
6
+ this.meters = meters;
7
+ }
8
+ add(line) {
9
+ this.meters.forEach((meter) => meter.add(line));
10
+ }
11
+ }
package/src/model.js ADDED
@@ -0,0 +1,25 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ export class Model {
5
+ constructor() {
6
+ this.names = [];
7
+ }
8
+ add(line) {
9
+ try {
10
+ const json = JSON.parse(line);
11
+ if (json.type === 'system' && json.subtype === 'init' && typeof json.model === 'string') {
12
+ this.names.push(json.model);
13
+ }
14
+ } catch {
15
+ return;
16
+ }
17
+ }
18
+ text() {
19
+ const parts = (this.names.at(-1) ?? '').replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-').filter((p) => p !== '');
20
+ return [
21
+ ...parts.filter((p) => !/^\d+$/.test(p)).map((p) => `${p[0].toUpperCase()}${p.slice(1)}`),
22
+ parts.filter((p) => /^\d+$/.test(p)).join('.')
23
+ ].join('');
24
+ }
25
+ }
package/src/permanent.js CHANGED
@@ -1,19 +1,10 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- /**
5
- * A wish that was already in the directory when jinka started.
6
- * It describes ongoing work, so its file is never deleted: closing it
7
- * only gives its claim back, sending it to the end of the queue.
8
- * @todo #10:45min A permanent wish goes back to the queue right after
9
- * a successful run, so a directory with a single permanent wish makes
10
- * the agent work on it without any pause, burning the subscription.
11
- * Let us decide whether a permanent wish must wait some time after
12
- * a success before it is tried again, and implement that delay.
13
- */
14
4
  export class Permanent {
15
- constructor(origin) {
5
+ constructor(origin, pause) {
16
6
  this.origin = origin;
7
+ this.pause = pause;
17
8
  }
18
9
  name() {
19
10
  return this.origin.name();
@@ -25,7 +16,7 @@ export class Permanent {
25
16
  return 'which stays there after you exit, so you must not delete it';
26
17
  }
27
18
  async close() {
28
- this.origin.free();
19
+ setTimeout(() => this.origin.free(), this.pause).unref();
29
20
  }
30
21
  free() {
31
22
  this.origin.free();
package/src/quota.js ADDED
@@ -0,0 +1,21 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ export class Quota {
5
+ constructor() {
6
+ this.shares = [];
7
+ }
8
+ add(line) {
9
+ try {
10
+ const json = JSON.parse(line);
11
+ if (json.type === 'rate_limit_event' && typeof json.rate_limit_info?.unifiedWindows?.five_hour?.utilization === 'number') {
12
+ this.shares.push(json.rate_limit_info.unifiedWindows.five_hour.utilization);
13
+ }
14
+ } catch {
15
+ return;
16
+ }
17
+ }
18
+ text() {
19
+ return this.shares.length === 0 ? '-' : `${Math.round((1 - this.shares.at(-1)) * 100)}%`;
20
+ }
21
+ }
package/src/row.js CHANGED
@@ -1,11 +1,6 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- /**
5
- * One line of the screen, owned by one wand.
6
- * Every text it receives goes to the screen together with the index
7
- * of the line, so the screen knows which line to change.
8
- */
9
4
  export class Row {
10
5
  constructor(lines, index) {
11
6
  this.lines = lines;
package/src/status.js CHANGED
@@ -1,19 +1,26 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { styleText } from 'node:util';
4
5
  import { Duration } from './duration.js';
5
6
 
6
- /**
7
- * The top line of the terminal screen.
8
- * It tells which version of jinka is running and how long it has been
9
- * alive since the moment it started.
10
- */
11
7
  export class Status {
12
- constructor(version, start) {
8
+ constructor(version, start, agent, model, tokens, quota) {
13
9
  this.version = version;
14
10
  this.start = start;
11
+ this.agent = agent;
12
+ this.model = model;
13
+ this.tokens = tokens;
14
+ this.quota = quota;
15
15
  }
16
16
  text() {
17
- return `jinka ${this.version}, up ${new Duration(Date.now() - this.start).hours()}`;
17
+ return [
18
+ `jinka ${styleText('bgGray', this.version, { validateStream: false })}`,
19
+ `up ${styleText('bgGray', new Duration(Date.now() - this.start).hours(), { validateStream: false })}`,
20
+ `agent ${styleText('bgGray', this.agent, { validateStream: false })}`,
21
+ `model ${styleText('bgGray', this.model === '' ? 'default' : this.model, { validateStream: false })}`,
22
+ `tokens ${styleText('bgGray', this.tokens.text(), { validateStream: false })}`,
23
+ `5h-quota [${styleText('bgGray', this.quota.text(), { validateStream: false })}]`
24
+ ].join(', ');
18
25
  }
19
26
  }
package/src/tokens.js ADDED
@@ -0,0 +1,26 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ export class Tokens {
5
+ constructor() {
6
+ this.messages = new Map();
7
+ }
8
+ add(line) {
9
+ try {
10
+ const json = JSON.parse(line);
11
+ if (json.type === 'assistant' && json.message?.id !== undefined && json.message?.usage !== undefined) {
12
+ this.messages.set(
13
+ json.message.id,
14
+ ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
15
+ .reduce((sum, key) => sum + (json.message.usage[key] ?? 0), 0)
16
+ );
17
+ }
18
+ } catch {
19
+ return;
20
+ }
21
+ }
22
+ text() {
23
+ return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 })
24
+ .format([...this.messages.values()].reduce((sum, count) => sum + count, 0));
25
+ }
26
+ }