estela 0.2.3 → 0.2.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/README.md +10 -0
- package/dist/cli.js +4 -0
- package/dist/i18n/en.js +2 -0
- package/dist/rate-history.d.ts +63 -0
- package/dist/rate-history.js +193 -0
- package/dist/statusline-cache.d.ts +10 -0
- package/dist/statusline-run.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -306,6 +306,16 @@ won't overwrite it: `--force` replaces it, `--append` shows yours after
|
|
|
306
306
|
Estela's. `estela statusline uninstall` removes it (and puts yours back if
|
|
307
307
|
you used `--append`).
|
|
308
308
|
|
|
309
|
+
The status line only runs in Claude Code in the terminal: the VS Code
|
|
310
|
+
extension doesn't show it.
|
|
311
|
+
|
|
312
|
+
Since 0.2.4, the status line stores your 5-hour and weekly percentages locally
|
|
313
|
+
(`~/.estela/rate-limits.jsonl`) so `estela plan` can analyse them. Nothing leaves
|
|
314
|
+
your machine. Only what Claude Code sends is kept — each percentage, its reset
|
|
315
|
+
time and the Claude Code version — at most every 5 minutes unless a figure moves,
|
|
316
|
+
and for 120 days. Transcripts don't carry these percentages, so the history
|
|
317
|
+
starts the day you install 0.2.4.
|
|
318
|
+
|
|
309
319
|
## Hours no import will infer
|
|
310
320
|
|
|
311
321
|
Meetings, travel, research, and development without an agent that didn't leave
|
package/dist/cli.js
CHANGED
|
@@ -2134,6 +2134,7 @@ function cmdStatuslineInstall(args) {
|
|
|
2134
2134
|
}
|
|
2135
2135
|
if (plan.status === "already") {
|
|
2136
2136
|
console.log((0, index_js_1.tr) `La status line de Estela ya está instalada en ${path}.`);
|
|
2137
|
+
console.log((0, index_js_1.tr) `Solo en Claude Code en la terminal: la extensión de VS Code no enseña la status line.`);
|
|
2137
2138
|
return;
|
|
2138
2139
|
}
|
|
2139
2140
|
if (plan.status === "conflict") {
|
|
@@ -2158,6 +2159,9 @@ function cmdStatuslineInstall(args) {
|
|
|
2158
2159
|
? (0, index_js_1.tr) `Status line de Estela instalada en lugar de: ${plan.previous ?? ""}`
|
|
2159
2160
|
: (0, index_js_1.tr) `Status line de Estela instalada en ${path}.`);
|
|
2160
2161
|
console.log((0, index_js_1.tr) `Se verá en la próxima sesión de Claude Code (o al reiniciar la actual).`);
|
|
2162
|
+
// Comprobado: la extensión de VS Code no ejecuta la status line. Sin este
|
|
2163
|
+
// aviso, quien trabaja ahí cree que está instalada y no la ve nunca.
|
|
2164
|
+
console.log((0, index_js_1.tr) `Solo en Claude Code en la terminal: la extensión de VS Code no enseña la status line.`);
|
|
2161
2165
|
if (!(0, statusline_install_js_1.estelaOnPath)()) {
|
|
2162
2166
|
console.log((0, index_js_1.tr) `\nAviso: "estela" no está en tu PATH, así que Claude Code no podrá ejecutarla.`);
|
|
2163
2167
|
console.log((0, index_js_1.tr) `Instálala de forma global: npm install -g estela`);
|
package/dist/i18n/en.js
CHANGED
|
@@ -577,5 +577,7 @@ exports.EN = {
|
|
|
577
577
|
"No hay ningún panel publicado de \"{0}\". Míralos con: estela unpublish": "There's no published dashboard for \"{0}\". See them with: estela unpublish",
|
|
578
578
|
// ── ESTELA_OFFLINE ──
|
|
579
579
|
"ESTELA_OFFLINE=1: Estela no se conecta a la red. Quita la variable para usar tu cuenta.": "ESTELA_OFFLINE=1: Estela doesn't connect to the network. Unset it to use your account.",
|
|
580
|
+
// ── statusline install: VS Code ──
|
|
581
|
+
"Solo en Claude Code en la terminal: la extensión de VS Code no enseña la status line.": "Only in Claude Code in the terminal: the VS Code extension doesn't show the status line.",
|
|
580
582
|
};
|
|
581
583
|
//# sourceMappingURL=en.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `~/.estela/rate-limits.jsonl`: el historial de los porcentajes de límite que
|
|
3
|
+
* Claude Code manda a la status line. Es lo que analiza `estela plan`.
|
|
4
|
+
*
|
|
5
|
+
* Solo se guarda lo que manda Claude Code, tal cual: el porcentaje usado y el
|
|
6
|
+
* reinicio de cada ventana, y su versión. Ni sesión, ni rutas, ni proyectos.
|
|
7
|
+
* Estela no estima ningún límite: sin `rate_limits` en la entrada (API key, o
|
|
8
|
+
* la primera respuesta de la sesión aún no llegó), no se guarda nada.
|
|
9
|
+
*
|
|
10
|
+
* Los transcripts no traen estos porcentajes, así que no hay forma de
|
|
11
|
+
* recuperar el pasado: el historial empieza el día que se instala la 0.2.4. Y
|
|
12
|
+
* solo se recoge con Claude Code en la terminal; la extensión de VS Code no
|
|
13
|
+
* ejecuta la status line.
|
|
14
|
+
*
|
|
15
|
+
* Va por el camino rápido de la status line (100 ms): sin `node:sqlite`, una
|
|
16
|
+
* línea por `appendFileSync`, y cualquier error se ignora.
|
|
17
|
+
*/
|
|
18
|
+
export interface RateSample {
|
|
19
|
+
/** Epoch en ms de la muestra. */
|
|
20
|
+
readonly ts: number;
|
|
21
|
+
readonly src: "claude-code";
|
|
22
|
+
readonly w5?: number;
|
|
23
|
+
/** `resets_at` tal cual (epoch en s). */
|
|
24
|
+
readonly w5_reset?: number;
|
|
25
|
+
readonly w7?: number;
|
|
26
|
+
readonly w7_reset?: number;
|
|
27
|
+
/** Versión de Claude Code, si viene. */
|
|
28
|
+
readonly cc?: string;
|
|
29
|
+
}
|
|
30
|
+
export declare const KEEP_DAYS = 120;
|
|
31
|
+
export declare function historyPath(dbPath: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* La muestra que sale del JSON de la status line, o null si no trae ningún
|
|
34
|
+
* porcentaje. Cada ventana que falte se omite: nunca se escribe un 0.
|
|
35
|
+
*/
|
|
36
|
+
export declare function sampleOf(raw: unknown, now: Date): RateSample | null;
|
|
37
|
+
/**
|
|
38
|
+
* Si vale la pena guardar la muestra: han pasado 5 minutos desde la última,
|
|
39
|
+
* algún porcentaje cambió al menos un punto, o empezó una ventana nueva.
|
|
40
|
+
*/
|
|
41
|
+
export declare function shouldWrite(last: RateSample | undefined, s: RateSample): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Lo que hace la status line en cada evento. Nunca lanza: es un historial, y
|
|
44
|
+
* la línea tiene que salir igual aunque no se pueda escribir.
|
|
45
|
+
*
|
|
46
|
+
* Solo escribe si ya existe el directorio de Estela: la status line no es
|
|
47
|
+
* quien empieza a guardar datos.
|
|
48
|
+
*/
|
|
49
|
+
export declare function recordSample(dbPath: string, raw: unknown, now: Date): void;
|
|
50
|
+
/** Lee el historial. Las líneas rotas se saltan. */
|
|
51
|
+
export declare function readSamples(file: string): RateSample[];
|
|
52
|
+
/**
|
|
53
|
+
* Quita las muestras de hace más de 120 días. Reescribe en un fichero aparte y
|
|
54
|
+
* lo renombra encima: un corte a mitad no deja el historial a medias.
|
|
55
|
+
* Devuelve cuántas quitó.
|
|
56
|
+
*
|
|
57
|
+
* Corre desde la status line una vez al día, así que lo normal (nada que
|
|
58
|
+
* quitar) tiene que costar casi nada: el fichero se escribe en orden, y si la
|
|
59
|
+
* primera muestra no ha caducado, ninguna lo ha hecho. Solo entonces se lee
|
|
60
|
+
* entero, y el `ts` se saca del principio de cada línea sin parsear el JSON.
|
|
61
|
+
*/
|
|
62
|
+
export declare function pruneSamples(file: string, now: Date, keepDays?: number): number;
|
|
63
|
+
//# sourceMappingURL=rate-history.d.ts.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KEEP_DAYS = void 0;
|
|
4
|
+
exports.historyPath = historyPath;
|
|
5
|
+
exports.sampleOf = sampleOf;
|
|
6
|
+
exports.shouldWrite = shouldWrite;
|
|
7
|
+
exports.recordSample = recordSample;
|
|
8
|
+
exports.readSamples = readSamples;
|
|
9
|
+
exports.pruneSamples = pruneSamples;
|
|
10
|
+
const node_fs_1 = require("node:fs");
|
|
11
|
+
const node_path_1 = require("node:path");
|
|
12
|
+
const statusline_cache_js_1 = require("./statusline-cache.js");
|
|
13
|
+
const MIN_GAP_MS = 5 * 60_000;
|
|
14
|
+
const MIN_DELTA = 1;
|
|
15
|
+
exports.KEEP_DAYS = 120;
|
|
16
|
+
const PRUNE_EVERY_MS = 24 * 60 * 60_000;
|
|
17
|
+
/** Cada cuánto se reescribe en la caché "se vio una sesión sin rate_limits". */
|
|
18
|
+
const NO_LIMITS_EVERY_MS = 60 * 60_000;
|
|
19
|
+
function historyPath(dbPath) {
|
|
20
|
+
return (0, node_path_1.join)((0, node_path_1.dirname)(dbPath), "rate-limits.jsonl");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* La muestra que sale del JSON de la status line, o null si no trae ningún
|
|
24
|
+
* porcentaje. Cada ventana que falte se omite: nunca se escribe un 0.
|
|
25
|
+
*/
|
|
26
|
+
function sampleOf(raw, now) {
|
|
27
|
+
const o = obj(raw);
|
|
28
|
+
const limits = obj(o["rate_limits"]);
|
|
29
|
+
const five = obj(limits["five_hour"]);
|
|
30
|
+
const seven = obj(limits["seven_day"]);
|
|
31
|
+
const w5 = num(five["used_percentage"]);
|
|
32
|
+
const w7 = num(seven["used_percentage"]);
|
|
33
|
+
if (w5 === undefined && w7 === undefined)
|
|
34
|
+
return null;
|
|
35
|
+
const w5Reset = num(five["resets_at"]);
|
|
36
|
+
const w7Reset = num(seven["resets_at"]);
|
|
37
|
+
const cc = typeof o["version"] === "string" && o["version"] ? o["version"] : undefined;
|
|
38
|
+
return {
|
|
39
|
+
ts: now.getTime(), src: "claude-code",
|
|
40
|
+
...(w5 !== undefined ? { w5 } : {}),
|
|
41
|
+
...(w5Reset !== undefined ? { w5_reset: w5Reset } : {}),
|
|
42
|
+
...(w7 !== undefined ? { w7 } : {}),
|
|
43
|
+
...(w7Reset !== undefined ? { w7_reset: w7Reset } : {}),
|
|
44
|
+
...(cc ? { cc } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Si vale la pena guardar la muestra: han pasado 5 minutos desde la última,
|
|
49
|
+
* algún porcentaje cambió al menos un punto, o empezó una ventana nueva.
|
|
50
|
+
*/
|
|
51
|
+
function shouldWrite(last, s) {
|
|
52
|
+
if (!last)
|
|
53
|
+
return true;
|
|
54
|
+
if (s.ts - last.ts >= MIN_GAP_MS)
|
|
55
|
+
return true;
|
|
56
|
+
if (moved(last.w5, s.w5) || moved(last.w7, s.w7))
|
|
57
|
+
return true;
|
|
58
|
+
return last.w5_reset !== s.w5_reset || last.w7_reset !== s.w7_reset;
|
|
59
|
+
}
|
|
60
|
+
function moved(a, b) {
|
|
61
|
+
if (a === undefined || b === undefined)
|
|
62
|
+
return a !== b;
|
|
63
|
+
return Math.abs(a - b) >= MIN_DELTA;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Lo que hace la status line en cada evento. Nunca lanza: es un historial, y
|
|
67
|
+
* la línea tiene que salir igual aunque no se pueda escribir.
|
|
68
|
+
*
|
|
69
|
+
* Solo escribe si ya existe el directorio de Estela: la status line no es
|
|
70
|
+
* quien empieza a guardar datos.
|
|
71
|
+
*/
|
|
72
|
+
function recordSample(dbPath, raw, now) {
|
|
73
|
+
try {
|
|
74
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.dirname)(dbPath)))
|
|
75
|
+
return;
|
|
76
|
+
const cache = (0, statusline_cache_js_1.loadCache)(dbPath);
|
|
77
|
+
let changed = false;
|
|
78
|
+
const sample = sampleOf(raw, now);
|
|
79
|
+
if (!sample) {
|
|
80
|
+
// Para que `estela plan` distinga "sin datos" de "esto es una API key".
|
|
81
|
+
if (obj(raw)["rate_limits"] === undefined && stale(cache.noLimitsAt, now, NO_LIMITS_EVERY_MS)) {
|
|
82
|
+
cache.noLimitsAt = now.toISOString();
|
|
83
|
+
changed = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (shouldWrite(cache.lastSample, sample)) {
|
|
87
|
+
(0, node_fs_1.appendFileSync)(historyPath(dbPath), JSON.stringify(sample) + "\n");
|
|
88
|
+
cache.lastSample = sample;
|
|
89
|
+
changed = true;
|
|
90
|
+
}
|
|
91
|
+
if (sample && stale(cache.prunedAt, now, PRUNE_EVERY_MS)) {
|
|
92
|
+
pruneSamples(historyPath(dbPath), now);
|
|
93
|
+
cache.prunedAt = now.toISOString();
|
|
94
|
+
changed = true;
|
|
95
|
+
}
|
|
96
|
+
if (changed)
|
|
97
|
+
(0, statusline_cache_js_1.saveCache)(dbPath, cache);
|
|
98
|
+
}
|
|
99
|
+
catch { /* sin historial esta vez; la línea sale igual */ }
|
|
100
|
+
}
|
|
101
|
+
function stale(iso, now, ms) {
|
|
102
|
+
if (!iso)
|
|
103
|
+
return true;
|
|
104
|
+
const t = Date.parse(iso);
|
|
105
|
+
return Number.isNaN(t) || now.getTime() - t >= ms;
|
|
106
|
+
}
|
|
107
|
+
/** Lee el historial. Las líneas rotas se saltan. */
|
|
108
|
+
function readSamples(file) {
|
|
109
|
+
let text;
|
|
110
|
+
try {
|
|
111
|
+
text = (0, node_fs_1.readFileSync)(file, "utf8");
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const line of text.split("\n")) {
|
|
118
|
+
if (!line)
|
|
119
|
+
continue;
|
|
120
|
+
try {
|
|
121
|
+
const s = JSON.parse(line);
|
|
122
|
+
if (s && typeof s.ts === "number")
|
|
123
|
+
out.push(s);
|
|
124
|
+
}
|
|
125
|
+
catch { /* línea a medias: se ignora */ }
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Quita las muestras de hace más de 120 días. Reescribe en un fichero aparte y
|
|
131
|
+
* lo renombra encima: un corte a mitad no deja el historial a medias.
|
|
132
|
+
* Devuelve cuántas quitó.
|
|
133
|
+
*
|
|
134
|
+
* Corre desde la status line una vez al día, así que lo normal (nada que
|
|
135
|
+
* quitar) tiene que costar casi nada: el fichero se escribe en orden, y si la
|
|
136
|
+
* primera muestra no ha caducado, ninguna lo ha hecho. Solo entonces se lee
|
|
137
|
+
* entero, y el `ts` se saca del principio de cada línea sin parsear el JSON.
|
|
138
|
+
*/
|
|
139
|
+
function pruneSamples(file, now, keepDays = exports.KEEP_DAYS) {
|
|
140
|
+
const cutoff = now.getTime() - keepDays * 24 * 60 * 60_000;
|
|
141
|
+
const first = firstLine(file);
|
|
142
|
+
if (first === null)
|
|
143
|
+
return 0;
|
|
144
|
+
const firstTs = tsOf(first);
|
|
145
|
+
if (firstTs !== null && firstTs >= cutoff)
|
|
146
|
+
return 0;
|
|
147
|
+
const lines = (0, node_fs_1.readFileSync)(file, "utf8").split("\n").filter(Boolean);
|
|
148
|
+
const kept = lines.filter((line) => {
|
|
149
|
+
const ts = tsOf(line);
|
|
150
|
+
return ts !== null && ts >= cutoff;
|
|
151
|
+
});
|
|
152
|
+
if (kept.length === lines.length)
|
|
153
|
+
return 0;
|
|
154
|
+
const tmp = `${file}.tmp`;
|
|
155
|
+
(0, node_fs_1.writeFileSync)(tmp, kept.map((l) => l + "\n").join(""));
|
|
156
|
+
(0, node_fs_1.renameSync)(tmp, file);
|
|
157
|
+
return lines.length - kept.length;
|
|
158
|
+
}
|
|
159
|
+
/** El `ts` de una línea: siempre es el primer campo, porque así la escribe `sampleOf`. Null si está rota. */
|
|
160
|
+
function tsOf(line) {
|
|
161
|
+
const m = /^\{"ts":(\d+),/.exec(line);
|
|
162
|
+
if (!m || !line.endsWith("}"))
|
|
163
|
+
return null;
|
|
164
|
+
return Number(m[1]);
|
|
165
|
+
}
|
|
166
|
+
function firstLine(file) {
|
|
167
|
+
let fd;
|
|
168
|
+
try {
|
|
169
|
+
fd = (0, node_fs_1.openSync)(file, "r");
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const buf = Buffer.alloc(512);
|
|
176
|
+
const n = (0, node_fs_1.readSync)(fd, buf, 0, buf.length, 0);
|
|
177
|
+
if (n === 0)
|
|
178
|
+
return null;
|
|
179
|
+
const text = buf.subarray(0, n).toString("utf8");
|
|
180
|
+
const nl = text.indexOf("\n");
|
|
181
|
+
return nl >= 0 ? text.slice(0, nl) : text;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
(0, node_fs_1.closeSync)(fd);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function obj(v) {
|
|
188
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
189
|
+
}
|
|
190
|
+
function num(v) {
|
|
191
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=rate-history.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RateSample } from "./rate-history.js";
|
|
1
2
|
/**
|
|
2
3
|
* `statusline.json`, junto a la base: lo que la status line recuerda entre
|
|
3
4
|
* llamadas.
|
|
@@ -7,6 +8,9 @@
|
|
|
7
8
|
* (porcentaje y reinicio) y cuándo se vio. La status line lo enseña al
|
|
8
9
|
* arrancar una sesión, antes de que lleguen datos nuevos, y `estela quota`
|
|
9
10
|
* usa el reinicio de la 5 h para saber dónde empieza de verdad la ventana.
|
|
11
|
+
* - `lastSample`, `prunedAt`, `noLimitsAt`: del historial de
|
|
12
|
+
* `rate-limits.jsonl` (ver rate-history.ts), para decidir si se guarda una
|
|
13
|
+
* muestra sin tener que leer el fichero.
|
|
10
14
|
*
|
|
11
15
|
* Es una caché: si se pierde o se corrompe, todo sigue funcionando, solo que
|
|
12
16
|
* relee más o vuelve a estimar.
|
|
@@ -25,6 +29,12 @@ export interface StatuslineCache {
|
|
|
25
29
|
}>;
|
|
26
30
|
fiveHour?: SeenLimit;
|
|
27
31
|
sevenDay?: SeenLimit;
|
|
32
|
+
/** La última muestra guardada en rate-limits.jsonl. */
|
|
33
|
+
lastSample?: RateSample;
|
|
34
|
+
/** Cuándo se quitaron por última vez las muestras de más de 120 días. */
|
|
35
|
+
prunedAt?: string;
|
|
36
|
+
/** La última vez que llegó una entrada sin rate_limits (API key, o sesión recién abierta). */
|
|
37
|
+
noLimitsAt?: string;
|
|
28
38
|
}
|
|
29
39
|
export declare function cachePath(dbPath: string): string;
|
|
30
40
|
export declare function loadCache(dbPath: string): StatuslineCache;
|
package/dist/statusline-run.js
CHANGED
|
@@ -43,6 +43,7 @@ const store = __importStar(require("./db/store.js"));
|
|
|
43
43
|
const quota_js_1 = require("./metrics/quota.js");
|
|
44
44
|
const paths_js_1 = require("./paths.js");
|
|
45
45
|
const project_of_js_1 = require("./project-of.js");
|
|
46
|
+
const rate_history_js_1 = require("./rate-history.js");
|
|
46
47
|
const statusline_cache_js_1 = require("./statusline-cache.js");
|
|
47
48
|
const statusline_js_1 = require("./statusline.js");
|
|
48
49
|
const claude_js_1 = require("./watchers/claude.js");
|
|
@@ -65,6 +66,13 @@ async function runStatusline(argv) {
|
|
|
65
66
|
}, now);
|
|
66
67
|
}
|
|
67
68
|
catch { /* es una caché: sin ella, la línea sale con lo que llegó */ }
|
|
69
|
+
// El historial de porcentajes para `estela plan`. No lanza nunca.
|
|
70
|
+
if (text.trim()) {
|
|
71
|
+
try {
|
|
72
|
+
(0, rate_history_js_1.recordSample)(flags.db, JSON.parse(text), now);
|
|
73
|
+
}
|
|
74
|
+
catch { /* entrada rota: ya lo cubre lo de abajo */ }
|
|
75
|
+
}
|
|
68
76
|
const data = computeStatus({ ...input, ...limits }, flags.db, now);
|
|
69
77
|
const ours = (0, statusline_js_1.renderStatus)(data, { columns, color: flags.color, reserved });
|
|
70
78
|
process.stdout.write((extra ? `${ours}${flags.color ? "\x1b[90m | \x1b[0m" : " | "}${extra}` : ours) + "\n");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "estela",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Time tracking you never fill in: rebuilds your billable hours from Claude Code, Codex and GitHub Copilot sessions and your Git history, and splits what the AI cost per project.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"time-tracking",
|