logdig 0.1.2 → 0.2.1
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/QUICKSTART.md +15 -1
- package/README.md +48 -6
- package/bin/logdig.js +63 -32
- package/package.json +1 -1
- package/src/backfill-progress.js +136 -0
- package/src/cli-status.js +25 -0
- package/src/config-menu.js +135 -0
- package/src/index.js +5 -3
- package/src/journal.js +24 -8
- package/src/session-runner.js +60 -23
- package/src/settings.js +36 -5
- package/src/setup.js +11 -3
- package/src/transcript.js +71 -1
- package/src/work-blocks.js +2 -0
package/QUICKSTART.md
CHANGED
|
@@ -18,13 +18,16 @@ In the wizard:
|
|
|
18
18
|
- **Daily-notes folder:** choose the folder holding `YYYY-MM-DD.md` notes, for example `My Vault/Daily`.
|
|
19
19
|
- **Summary cache:** choose `My Vault/LogDig` to keep the full summaries visible in Obsidian.
|
|
20
20
|
- **Heading and length:** `# Projects` and `small` are a good first try. Your personal `# Log` stays separate.
|
|
21
|
+
- **Anchor heading:** optionally choose `# Log` to create Projects at the end of the Log section, before the next same-level or higher-level heading. The default is `none` (blank): append at the end of the note. Missing anchors also append at the end; existing Projects sections stay in place.
|
|
21
22
|
- **Timezone:** check that this is the timezone you journal in.
|
|
22
|
-
- **Advanced settings:** choose a custom Pi history folder, executable, model,
|
|
23
|
+
- **Advanced settings:** choose a custom Pi history folder, executable, model, thinking level, or concurrency here. Thinking defaults to no LogDig override; choose an explicit level such as `max` for a cost-first reasoning model if you are willing to wait longer. Concurrency defaults to four independent sessions; use `1` for sequential processing.
|
|
23
24
|
- **Automatic capture:** choose **no** for now.
|
|
24
25
|
- **Pi extension:** optional. The CLI works without it.
|
|
25
26
|
|
|
26
27
|
Press Enter to accept defaults. A typo only re-asks that question. Review the choices before saving. Ctrl+C or declining confirmation leaves your settings unchanged. Setup writes settings only, not summaries or daily notes.
|
|
27
28
|
|
|
29
|
+
To change a setting later, run `logdig config`. Choose its number or type a name such as `thinking`, then enter the new value. Available choices are shown first. Enter keeps the current value; each valid change saves immediately and refreshes the list. Type `q` and Enter to exit. Environment overrides are labeled and still take precedence. Redirected output stays read-only.
|
|
30
|
+
|
|
28
31
|
## 2. Check and preview
|
|
29
32
|
|
|
30
33
|
```sh
|
|
@@ -41,6 +44,17 @@ logdig backfill 1 --dry-run
|
|
|
41
44
|
logdig backfill 7 --dry-run
|
|
42
45
|
```
|
|
43
46
|
|
|
47
|
+
To backfill older work without spending model requests on ongoing sessions, add `--skip-today`:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
logdig backfill 7 --skip-today --dry-run
|
|
51
|
+
logdig backfill 7 --skip-today
|
|
52
|
+
logdig backfill 14 --skip-today
|
|
53
|
+
logdig backfill 30 --skip-today
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This selects complete calendar days through yesterday in your timezone and excludes whole work periods that continued today. Earlier periods in the same session can still be logged. Each wider run reuses unchanged summaries. The flag also works with `backfill all` and `status`.
|
|
57
|
+
|
|
44
58
|
If no history is found at all, start a saved Pi session or select the right history folder in the wizard's advanced settings.
|
|
45
59
|
|
|
46
60
|
## 3. Make the first entry
|
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ For a comfortable first try:
|
|
|
30
30
|
- Choose the folder **inside your vault** holding your daily notes, such as `My Vault/Daily`.
|
|
31
31
|
- Put the summary cache in `My Vault/LogDig` if you want to browse it in Obsidian.
|
|
32
32
|
- Keep the `# Projects` heading and **small** summary unless you prefer otherwise. Your personal `# Log` section stays separate.
|
|
33
|
+
- Optionally choose `# Log` as the anchor heading to create Projects after your Log section, before the next sibling heading. Leave it unset to append at the end.
|
|
33
34
|
- Check the timezone. It determines the journal date and time.
|
|
34
35
|
- Leave automatic capture **off** until you have tried a manual run. Pi integration is optional.
|
|
35
36
|
|
|
@@ -67,7 +68,7 @@ My Vault/
|
|
|
67
68
|
└── <entry-id>.md snapshot of all three layers for a journal entry
|
|
68
69
|
```
|
|
69
70
|
|
|
70
|
-
Daily entries live under `# Projects`, grouped by project in first-seen order, with timestamps sorted within each project. For [try](https://github.com/tobi/try)-style directories, a leading `YYYY-MM-DD-` is omitted from the project label: `2026-01-17-learn` becomes `learn`. The source path is unchanged.
|
|
71
|
+
Daily entries live under `# Projects`, grouped by project in first-seen order, with timestamps sorted within each project. For Git worktrees, LogDig uses the shared repository's name rather than the worktree directory's name. For [try](https://github.com/tobi/try)-style directories, a leading `YYYY-MM-DD-` is omitted from the project label: `2026-01-17-learn` becomes `learn`. The source path is unchanged.
|
|
71
72
|
|
|
72
73
|
For example:
|
|
73
74
|
|
|
@@ -104,6 +105,22 @@ The block's **starting date and time** supply its journal timestamp. Continuous
|
|
|
104
105
|
|
|
105
106
|
Date ranges select actual conversation activity, not just assigned journal dates. Today's backfill therefore catches assistant completion or continued work after midnight and updates yesterday's entry. If a selected continuation has no preceding snapshot, LogDig includes the missing earlier block(s) as prerequisites. Status and preview explicitly show these additions, including their summary-generation cost implications.
|
|
106
107
|
|
|
108
|
+
### Backfill past work without touching today
|
|
109
|
+
|
|
110
|
+
Use `--skip-today` with CLI `backfill` or `status` to select the last N **complete calendar days**, ending yesterday in your configured timezone. For example, `backfill 1 --skip-today` selects yesterday, and `backfill 7 --skip-today` selects seven days through yesterday. `all --skip-today` selects all past work.
|
|
111
|
+
|
|
112
|
+
Work periods with conversation activity today are excluded entirely, even if they started before midnight. Earlier periods in the same session remain eligible. This avoids generating partial summaries or updating an ongoing period's existing cache or journal entry. Metadata-only activity does not exclude a period. Missing earlier continuation snapshots are still included as prerequisites.
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
logdig backfill 7 --skip-today --dry-run
|
|
116
|
+
logdig backfill 7 --skip-today
|
|
117
|
+
logdig backfill 14 --skip-today
|
|
118
|
+
logdig backfill 30 --skip-today
|
|
119
|
+
logdig backfill all --skip-today
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Widening the range reuses unchanged summaries and fills the additional history. Status and preview show the selected dates and the number of excluded work periods; status JSON includes `timeframe.skipToday` and `totals.excludedToday` when the flag is set. Without the flag, the normal range still includes today.
|
|
123
|
+
|
|
107
124
|
### Summary reuse and existing journals
|
|
108
125
|
|
|
109
126
|
Summary freshness is based on the selected, redacted evidence and bounded earlier context, plus summary-processing version, timezone, and configured generation policy. Earlier context is drawn from the preceding block's evidence, not its generated summary, and is marked as background rather than work to repeat. Metadata-only changes do not trigger model requests; usage totals can refresh separately. Explicit LogDig model or thinking-level changes invalidate summaries, while changes to Pi's defaults do not force regeneration under the default policy.
|
|
@@ -123,23 +140,25 @@ After a global install or `npm link`, use `logdig` from any directory. All comma
|
|
|
123
140
|
```text
|
|
124
141
|
logdig init configure paths, summaries, and optional Pi integration
|
|
125
142
|
logdig doctor check paths and Pi, with no model request
|
|
126
|
-
logdig config
|
|
143
|
+
logdig config edit settings by number or name; q quits
|
|
127
144
|
logdig --version show the installed version
|
|
128
145
|
logdig backfill journal the last 3 calendar days
|
|
129
146
|
logdig backfill 7 --dry-run preview seven days without changing anything
|
|
130
147
|
logdig backfill all --dry-run preview every discoverable saved session
|
|
131
148
|
logdig backfill 7 journal seven days
|
|
149
|
+
logdig backfill 7 --skip-today journal seven complete days through yesterday
|
|
132
150
|
logdig backfill 7 --model provider/model --thinking max
|
|
133
151
|
logdig backfill 7 --model default ignore a saved model override for this run
|
|
134
152
|
logdig backfill 7 --thinking default ignore a saved thinking override for this run
|
|
135
153
|
logdig status show coverage for the last 3 calendar days
|
|
136
154
|
logdig status 7 show coverage for seven days
|
|
155
|
+
logdig status 7 --skip-today show coverage excluding work active today
|
|
137
156
|
logdig status all --json output coverage for every saved session as JSON
|
|
138
157
|
logdig pi-install install the /journal extension
|
|
139
158
|
logdig pi-uninstall remove it without deleting notes or summaries
|
|
140
159
|
```
|
|
141
160
|
|
|
142
|
-
`--help` works before setup, including `logdig init --help`, `logdig backfill --help`, and `logdig status --help`.
|
|
161
|
+
`--help` works before setup, including `logdig init --help`, `logdig config --help`, `logdig backfill --help`, and `logdig status --help`.
|
|
143
162
|
|
|
144
163
|
`status` is a read-only coverage check. It counts work blocks: **logged** means the expected entry is present, **stale** means a previous snapshot exists but the entry needs updating, and **new** means no previous block snapshot was found. Summaries are reported separately as reusable or needing summarization. These are block counts, not exact request counts. It selects blocks by conversation activity in your configured timezone, including overnight updates and any missing continuation prerequisites. It never calls a model or writes files; scan warnings make the command exit nonzero so incomplete coverage is clear. JSON retains `sessions` as the result array, with one row per work block and both `sessionId` and `blockId`; totals use `logged`, `stale`, `new`, and `needsSummarizing`.
|
|
145
164
|
|
|
@@ -192,13 +211,36 @@ Settings contain paths and preferences, never provider credentials:
|
|
|
192
211
|
- macOS: `~/Library/Application Support/LogDig/settings.json`
|
|
193
212
|
- Windows: `%APPDATA%\LogDig\settings.json`
|
|
194
213
|
|
|
214
|
+
### Change one setting
|
|
215
|
+
|
|
216
|
+
Run `logdig config` in a terminal to see a numbered list of settings and current saved values. Enter a number (including `10`, `11`, or `12`) or a setting name such as `thinking` or `parallel`. Allowed values and a short explanation appear before you enter a new value. Enter keeps the current value; invalid input re-asks only that field. Each valid change saves immediately and shows the updated list. Type `q` and Enter at either prompt to exit. Ctrl+C or ending input also exits; completed saves are kept.
|
|
217
|
+
|
|
218
|
+
Use `default` to clear a model or thinking override, and `none` to clear the anchor heading. Active environment overrides are labeled next to their saved preferences and are never copied into the settings file. Changing a preference does not defeat its environment override. Enabling automatic capture still requires the Pi extension (`logdig pi-install`). Configuration never requests summaries, writes notes, or creates note/cache folders. Run `init` first if those folders have not been configured.
|
|
219
|
+
|
|
220
|
+
The menu uses Node's built-in line prompts with no runtime dependencies. Save confirmations follow the existing Nerd Font, `LOGDIG_ICONS=0`, and `NO_COLOR` conventions. `TERM=dumb` uses plain prompts. When input or output is redirected, `config` stays read-only and shows effective settings as formatted JSON with the settings path and override names, as before. For example: `logdig config > settings-report.txt`.
|
|
221
|
+
|
|
222
|
+
### Journal preferences
|
|
223
|
+
|
|
224
|
+
`dailyHeader` selects the section for project entries (default `# Projects`). The optional `dailyHeaderAnchor` selects where to create that section:
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{
|
|
228
|
+
"dailyHeader": "# Projects",
|
|
229
|
+
"dailyHeaderAnchor": "# Log"
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
This inserts a new Projects section after the entire Log section, including its subheadings, and before the next same-level or higher-level heading. Use the same heading level for both settings to keep the sections as siblings. The anchor must be one Markdown heading, including its `#` level, and matches the first occurrence outside frontmatter and fenced code. The default is `""` (blank): append at the end of the note. A missing anchor also falls back to the end. Existing Projects sections stay where they are; this setting does not relocate them or regenerate summaries. Run `config` to edit the anchor or choose `none` to clear it. `PI_JOURNAL_DAILY_HEADER_ANCHOR` overrides the saved setting, including an empty value to clear it.
|
|
234
|
+
|
|
235
|
+
Backfill and automatic capture process up to **four independent sessions concurrently** by default. Set `concurrency` in the settings file or choose “Maximum parallel sessions” in setup's advanced settings. It must be a positive integer; use `1` for sequential processing or to reduce provider rate-limit pressure. Work blocks and extraction requests within each session remain sequential to preserve continuation links. Shared daily-note updates are serialized to avoid overwriting entries. CLI result rows appear in session order even when parallel sessions finish out of order. Session numbers are zero-padded to match the total, for example `[01/65]`, followed by the date, a fixed-width status column, project, and short session ID. Each work period has one final result row; resumed sessions can have multiple dated rows with the same session number. In interactive terminals, a bounded live panel below the permanent results separates active sessions from finished results. Its progress line shows completed, active, and queued (not yet started) session counts. Active rows show the elapsed time in their current stage (`CHECKING` or `SUMMARIZING`), refreshed every second even while the model is silent. Finished sessions no longer occupy active rows. Instead, a message such as “20 finished sessions waiting to print after #021” identifies the earlier session holding up result display; completed counts include these unprinted results. Short terminals show fewer active rows and indicate how many are not shown. The panel redraws in place and clears when the run finishes. A slow earlier session can delay printing later result rows, but other sessions keep processing and picking up queued work. Redirected output and `TERM=dumb` use plain `Active` and `Finished` log lines instead of cursor controls. Dry-run output stays static. Blocks outside the selected range that are needed for continuation links are marked `prerequisite`. The final checked count distinguishes work blocks from sessions. Status reports retain chronological session order. Changing concurrency does not invalidate cached summaries. Avoid running separate LogDig commands against the same notes at the same time; the write queue is local to one run.
|
|
236
|
+
|
|
195
237
|
Set `LOGDIG_CONFIG_PATH` to choose another settings file. Existing `PI_JOURNAL_*` environment variables remain supported and override saved values. Setup, `config`, and `doctor` name active overrides so you can see why a saved preference is not taking effect.
|
|
196
238
|
|
|
197
239
|
- **No saved history found:** create a saved Pi session, or use setup's advanced settings to select your history folder. This is especially useful with a custom Pi session directory.
|
|
198
|
-
- **Notes went to the wrong folder:** run `config`, check environment overrides,
|
|
199
|
-
- **Pi cannot start:** run `doctor`, then use
|
|
240
|
+
- **Notes went to the wrong folder:** run `config`, check environment overrides, and edit the daily-notes folder rather than the vault root.
|
|
241
|
+
- **Pi cannot start:** run `doctor`, then use `config` to set the executable path.
|
|
200
242
|
- **A summary or note failed:** read the error, fix the path, permissions, authentication, or provider issue, and rerun. Successful cached work is kept.
|
|
201
|
-
- **Want to stop automatic capture:**
|
|
243
|
+
- **Want to stop automatic capture:** run `config` and set automatic capture to `no`, or set `PI_JOURNAL_AUTO=0`.
|
|
202
244
|
|
|
203
245
|
## Development
|
|
204
246
|
|
package/bin/logdig.js
CHANGED
|
@@ -9,7 +9,9 @@ import { spawnPiProcess } from "../src/pi-process.js";
|
|
|
9
9
|
import { environmentOverrides, loadSettings, validateSettings } from "../src/settings.js";
|
|
10
10
|
import { checkDirectory } from "../src/directories.js";
|
|
11
11
|
import { configureLogDig } from "../src/setup.js";
|
|
12
|
+
import { editConfig } from "../src/config-menu.js";
|
|
12
13
|
import { statusPrefix, statusPrefixWidth } from "../src/cli-status.js";
|
|
14
|
+
import { createBackfillProgress } from "../src/backfill-progress.js";
|
|
13
15
|
|
|
14
16
|
const packageRoot = fileURLToPath(new URL("../", import.meta.url));
|
|
15
17
|
const relativeEntry = path.relative(process.cwd(), fileURLToPath(import.meta.url)).split(path.sep).join("/");
|
|
@@ -71,6 +73,7 @@ function runPiCommand(command, args, { cwd = process.cwd(), inherit = false, tim
|
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
async function showConfig() {
|
|
76
|
+
if (process.stdin.isTTY && process.stdout.isTTY) return editConfig(commandName);
|
|
74
77
|
const settings = await loadSettings();
|
|
75
78
|
const { filePath, configured, ...values } = settings;
|
|
76
79
|
console.log(`Settings: ${filePath}${configured ? "" : " (not created yet)"}`);
|
|
@@ -126,6 +129,7 @@ async function doctor() {
|
|
|
126
129
|
}
|
|
127
130
|
console.log(`\nSummary model: ${settings.model || "Pi startup default"}`);
|
|
128
131
|
console.log(`Thinking: ${settings.thinkingLevel || "Pi startup default"}`);
|
|
132
|
+
console.log(`Parallel sessions: ${settings.concurrency}`);
|
|
129
133
|
console.log("Authentication is checked only when you request a summary. If it fails, open Pi and run /login.");
|
|
130
134
|
if (issues) {
|
|
131
135
|
console.error(`\n${issues} thing${issues === 1 ? "" : "s"} to fix. Address the FIX lines above, then run '${commandName} doctor' again.`);
|
|
@@ -139,12 +143,15 @@ async function backfill(args) {
|
|
|
139
143
|
const settings = await loadSettings();
|
|
140
144
|
let daysArgument;
|
|
141
145
|
let dryRun = false;
|
|
146
|
+
let skipToday = false;
|
|
142
147
|
let selectedModel = settings.model;
|
|
143
148
|
let selectedThinking = settings.thinkingLevel;
|
|
144
149
|
for (let index = 0; index < args.length; index++) {
|
|
145
150
|
const arg = args[index];
|
|
146
151
|
if (arg === "--dry-run") {
|
|
147
152
|
dryRun = true;
|
|
153
|
+
} else if (arg === "--skip-today") {
|
|
154
|
+
skipToday = true;
|
|
148
155
|
} else if (arg === "--model" || arg === "-m") {
|
|
149
156
|
selectedModel = args[++index];
|
|
150
157
|
if (!selectedModel || selectedModel.startsWith("-")) throw new Error("--model requires provider/model, or 'default' to use Pi's startup model");
|
|
@@ -168,16 +175,17 @@ async function backfill(args) {
|
|
|
168
175
|
const range = parseBackfillArgument(daysArgument);
|
|
169
176
|
requireJournalPaths(settings);
|
|
170
177
|
|
|
171
|
-
console.log(`${dryRun ? "Preview" : "Backfill"}: ${range.all ? "all saved sessions" : `last ${range.days} day${range.days === 1 ? "" : "s"}`} (${settings.timeZone})`);
|
|
178
|
+
console.log(`${dryRun ? "Preview" : "Backfill"}: ${range.all ? "all saved sessions" : `last ${range.days}${skipToday ? " complete" : ""} day${range.days === 1 ? "" : "s"}`} (${settings.timeZone})${skipToday ? " · skipping today" : ""}`);
|
|
172
179
|
console.log(`Daily notes: ${settings.dailyDirectory} (${settings.dailyHeader}, ${settings.dailySummary})`);
|
|
173
180
|
console.log(`Summary cache: ${path.join(settings.cacheDirectory, "Sessions")}`);
|
|
174
181
|
console.log(`Model: ${selectedModel || "Pi startup default"}`);
|
|
175
182
|
console.log(`Thinking: ${selectedThinking || "Pi startup default"}`);
|
|
183
|
+
console.log(`Parallel sessions: ${settings.concurrency}`);
|
|
176
184
|
console.log(dryRun
|
|
177
185
|
? "Dry run: no model requests, no file changes, and no folders created."
|
|
178
186
|
: "Only new or changed work blocks need summarizing. Selected, redacted history is sent to Pi's model; provider charges may apply.");
|
|
179
|
-
const found = await collectSessions({ sessionDirectory: settings.sessionDirectory, timeZone: settings.timeZone, days: range.days });
|
|
180
|
-
console.log(`\nFound ${found.sessions.length} saved Pi session${found.sessions.length === 1 ? "" : "s"}${found.firstDate ? ` to check for ${found.firstDate} through ${found.lastDate}` : ""}.`);
|
|
187
|
+
const found = await collectSessions({ sessionDirectory: settings.sessionDirectory, timeZone: settings.timeZone, days: range.days, skipToday });
|
|
188
|
+
console.log(`\nFound ${found.sessions.length} saved Pi session${found.sessions.length === 1 ? "" : "s"}${found.firstDate ? ` to check for ${found.firstDate} through ${found.lastDate}` : skipToday ? ` to check through ${found.lastDate}` : ""}.`);
|
|
181
189
|
if (found.sessions.length === 0) {
|
|
182
190
|
console.log(`No saved sessions found. History folder: ${settings.sessionDirectory}`);
|
|
183
191
|
console.log(`If you have saved history elsewhere, choose it in '${commandName} init' (advanced settings). Otherwise, start a Pi session first.`);
|
|
@@ -188,33 +196,45 @@ async function backfill(args) {
|
|
|
188
196
|
return;
|
|
189
197
|
}
|
|
190
198
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
199
|
+
console.log("Results are shown in session order; processing remains parallel.");
|
|
200
|
+
console.log("One result per work period; resumed sessions may have multiple dated rows.");
|
|
201
|
+
if (!dryRun) console.log("Summarizing may take a few minutes. Finished results wait to print until earlier sessions finish; other sessions keep processing.");
|
|
202
|
+
console.log();
|
|
203
|
+
const progress = createBackfillProgress({ total: found.sessions.length, concurrency: settings.concurrency, dryRun });
|
|
204
|
+
const interrupt = () => { cleanupProgress(); process.kill(process.pid, "SIGINT"); };
|
|
205
|
+
const terminate = () => { cleanupProgress(); process.kill(process.pid, "SIGTERM"); };
|
|
206
|
+
function cleanupProgress() {
|
|
207
|
+
progress.close();
|
|
208
|
+
process.removeListener("SIGINT", interrupt);
|
|
209
|
+
process.removeListener("SIGTERM", terminate);
|
|
210
|
+
}
|
|
211
|
+
process.once("SIGINT", interrupt);
|
|
212
|
+
process.once("SIGTERM", terminate);
|
|
213
|
+
let result;
|
|
214
|
+
try {
|
|
215
|
+
result = await writeSessions(createPiModelClient(generationSettings), found.sessions, settings, {
|
|
216
|
+
...found, dryRun, onProgress: progress.onProgress, onSessionComplete: progress.onSessionComplete,
|
|
217
|
+
});
|
|
218
|
+
} finally {
|
|
219
|
+
cleanupProgress();
|
|
220
|
+
}
|
|
205
221
|
const summaryCount = `${result.summariesCreated} ${result.summariesCreated === 1 ? "summary" : "summaries"}`;
|
|
206
222
|
const entryCount = `${result.entriesAppended} daily ${result.entriesAppended === 1 ? "entry" : "entries"}`;
|
|
207
223
|
const updateCount = `${result.entriesUpdated} ${result.entriesUpdated === 1 ? "entry" : "entries"}`;
|
|
224
|
+
const checkedBlocks = result.sessionResults.filter((entry) => entry.blockId);
|
|
225
|
+
const checkedSessions = new Set(checkedBlocks.map((entry) => entry.sessionId)).size;
|
|
226
|
+
console.log(`\nChecked: ${checkedBlocks.length} work block${checkedBlocks.length === 1 ? "" : "s"} across ${checkedSessions} session${checkedSessions === 1 ? "" : "s"}.`);
|
|
208
227
|
console.log(dryRun
|
|
209
|
-
?
|
|
210
|
-
:
|
|
211
|
-
if (result.sessionsSkipped) console.log(`${result.sessionsSkipped} session${result.sessionsSkipped === 1 ? "" : "s"} skipped (outside the date range or without journalable messages).`);
|
|
228
|
+
? `Would create ${summaryCount}, reuse ${result.summariesReused}, append ${entryCount}; ${result.entriesSkipped} already present; update ${updateCount}.`
|
|
229
|
+
: `Saved: ${summaryCount} created, ${result.summariesReused} reused, appended ${entryCount}; ${result.entriesSkipped} already present; updated ${updateCount}.`);
|
|
230
|
+
if (result.sessionsSkipped) console.log(`${result.sessionsSkipped} session${result.sessionsSkipped === 1 ? "" : "s"} skipped (outside the date range or without journalable messages${skipToday ? ", or active today" : ""}).`);
|
|
231
|
+
if (skipToday) console.log(`${result.blocksExcludedToday} work period${result.blocksExcludedToday === 1 ? "" : "s"} excluded because of activity today (--skip-today).`);
|
|
212
232
|
if (result.dates.length) {
|
|
213
233
|
console.log(`Daily-note dates: ${result.dates.join(", ")}`);
|
|
214
234
|
if (dryRun && result.summariesCreated) console.log(`${result.summariesCreated} work block${result.summariesCreated === 1 ? "" : "s"} would need summarizing. Large blocks may require multiple model requests each.`);
|
|
215
235
|
if (!result.summariesCreated && !result.errors.length) console.log("These unchanged work blocks need no model requests.");
|
|
216
236
|
} else if (!result.errors.length) {
|
|
217
|
-
console.log(`Nothing to journal in this range. Try '${commandName} backfill 7 --dry-run' or '${commandName} backfill all --dry-run'.`);
|
|
237
|
+
console.log(`Nothing to journal in this range. Try '${commandName} backfill 7${skipToday ? " --skip-today" : ""} --dry-run' or '${commandName} backfill all${skipToday ? " --skip-today" : ""} --dry-run'.`);
|
|
218
238
|
}
|
|
219
239
|
const warnings = [...found.warnings, ...result.errors];
|
|
220
240
|
if (warnings.length) {
|
|
@@ -222,35 +242,39 @@ async function backfill(args) {
|
|
|
222
242
|
console.warn(dryRun ? "Preview incomplete. Fix the issues above, then preview again." : "Some sessions could not be saved. Successful summaries are cached. Fix the issues above and rerun the same command to retry.");
|
|
223
243
|
process.exitCode = 1;
|
|
224
244
|
} else if (dryRun && result.dates.length) {
|
|
225
|
-
console.log(`\nWhen you're ready: ${commandName} backfill ${range.all ? "all" : range.days}${selectedModel ? ` --model ${selectedModel}` : settings.model ? " --model default" : ""}${selectedThinking ? ` --thinking ${selectedThinking}` : settings.thinkingLevel ? " --thinking default" : ""}`);
|
|
245
|
+
console.log(`\nWhen you're ready: ${commandName} backfill ${range.all ? "all" : range.days}${skipToday ? " --skip-today" : ""}${selectedModel ? ` --model ${selectedModel}` : settings.model ? " --model default" : ""}${selectedThinking ? ` --thinking ${selectedThinking}` : settings.thinkingLevel ? " --thinking default" : ""}`);
|
|
226
246
|
}
|
|
227
247
|
}
|
|
228
248
|
|
|
229
249
|
function parseStatusArguments(args) {
|
|
230
250
|
let rangeArgument;
|
|
231
251
|
let json = false;
|
|
252
|
+
let skipToday = false;
|
|
232
253
|
for (const arg of args) {
|
|
233
254
|
if (arg === "--json") {
|
|
234
255
|
json = true;
|
|
256
|
+
} else if (arg === "--skip-today") {
|
|
257
|
+
skipToday = true;
|
|
235
258
|
} else if (arg.startsWith("-")) {
|
|
236
259
|
throw new Error(`Unknown status argument: ${arg}. Run '${commandName} status --help'.`);
|
|
237
260
|
} else if (rangeArgument !== undefined) {
|
|
238
|
-
throw new Error(`Usage: ${commandName} status [number-of-days|all] [--json]`);
|
|
261
|
+
throw new Error(`Usage: ${commandName} status [number-of-days|all] [--json] [--skip-today]`);
|
|
239
262
|
} else {
|
|
240
263
|
rangeArgument = arg;
|
|
241
264
|
}
|
|
242
265
|
}
|
|
243
|
-
return { range: parseBackfillArgument(rangeArgument, "status"), json };
|
|
266
|
+
return { range: parseBackfillArgument(rangeArgument, "status"), json, skipToday };
|
|
244
267
|
}
|
|
245
268
|
|
|
246
269
|
async function status(args) {
|
|
247
|
-
const { range, json } = parseStatusArguments(args);
|
|
270
|
+
const { range, json, skipToday } = parseStatusArguments(args);
|
|
248
271
|
const settings = await loadSettings();
|
|
249
272
|
requireJournalPaths(settings);
|
|
250
273
|
const found = await collectSessions({
|
|
251
274
|
sessionDirectory: settings.sessionDirectory,
|
|
252
275
|
timeZone: settings.timeZone,
|
|
253
276
|
days: range.days,
|
|
277
|
+
skipToday,
|
|
254
278
|
});
|
|
255
279
|
const previousEntries = await listJournaledSessions(settings.cacheDirectory);
|
|
256
280
|
const inspection = await writeSessions(
|
|
@@ -295,6 +319,7 @@ async function status(args) {
|
|
|
295
319
|
firstDate: found.firstDate || null,
|
|
296
320
|
lastDate: found.lastDate,
|
|
297
321
|
timeZone: settings.timeZone,
|
|
322
|
+
...(skipToday ? { skipToday: true } : {}),
|
|
298
323
|
},
|
|
299
324
|
totals: {
|
|
300
325
|
scanned: found.sessions.length,
|
|
@@ -305,6 +330,7 @@ async function status(args) {
|
|
|
305
330
|
needsSummarizing,
|
|
306
331
|
skipped: inspection.sessionsSkipped,
|
|
307
332
|
errors: inspection.errors.length,
|
|
333
|
+
...(skipToday ? { excludedToday: inspection.blocksExcludedToday } : {}),
|
|
308
334
|
},
|
|
309
335
|
sessions,
|
|
310
336
|
warnings,
|
|
@@ -313,8 +339,9 @@ async function status(args) {
|
|
|
313
339
|
if (json) {
|
|
314
340
|
console.log(JSON.stringify(report, null, 2));
|
|
315
341
|
} else {
|
|
316
|
-
const label = range.all ? "all saved sessions" : `last ${range.days} calendar day${range.days === 1 ? "" : "s"}`;
|
|
317
|
-
console.log(`LogDig status: activity in ${label} (${settings.timeZone})${found.firstDate ? ` · ${found.firstDate} through ${found.lastDate}` : ""}`);
|
|
342
|
+
const label = range.all ? "all saved sessions" : `last ${range.days}${skipToday ? " complete" : ""} calendar day${range.days === 1 ? "" : "s"}`;
|
|
343
|
+
console.log(`LogDig status: activity in ${label} (${settings.timeZone})${found.firstDate ? ` · ${found.firstDate} through ${found.lastDate}` : skipToday ? ` · through ${found.lastDate}` : ""}${skipToday ? " · skipping today" : ""}`);
|
|
344
|
+
if (skipToday) console.log(`${inspection.blocksExcludedToday} work period${inspection.blocksExcludedToday === 1 ? "" : "s"} excluded because of activity today (--skip-today).`);
|
|
318
345
|
console.log(`${report.totals.eligible} work block${report.totals.eligible === 1 ? "" : "s"}: ${report.totals.logged} logged, ${stale} stale, ${newCount} new.`);
|
|
319
346
|
console.log(`Summaries: ${report.totals.eligible - needsSummarizing} reusable, ${needsSummarizing} work block${needsSummarizing === 1 ? " needs" : "s need"} summarizing.`);
|
|
320
347
|
const prerequisites = sessions.filter((session) => session.prerequisite).length;
|
|
@@ -334,7 +361,7 @@ async function status(args) {
|
|
|
334
361
|
console.log("Everything in this timeframe is current.");
|
|
335
362
|
}
|
|
336
363
|
if (newCount || stale || needsSummarizing) {
|
|
337
|
-
console.log(`\nTo update the journal: ${commandName} backfill ${range.all ? "all" : range.days}`);
|
|
364
|
+
console.log(`\nTo update the journal: ${commandName} backfill ${range.all ? "all" : range.days}${skipToday ? " --skip-today" : ""}`);
|
|
338
365
|
}
|
|
339
366
|
}
|
|
340
367
|
|
|
@@ -356,16 +383,20 @@ function helpText() {
|
|
|
356
383
|
"Commands (install with 'npm install -g logdig', or use 'npm link' in a checkout):",
|
|
357
384
|
" logdig init guided setup; nothing is summarized",
|
|
358
385
|
" logdig doctor check paths and Pi without a model request",
|
|
359
|
-
" logdig config
|
|
360
|
-
" logdig backfill [N|all] [--dry-run] [--
|
|
386
|
+
" logdig config edit numbered settings; q quits (redirected: read-only)",
|
|
387
|
+
" logdig backfill [N|all] [--dry-run] [--skip-today]",
|
|
388
|
+
" [--model provider/model|default]",
|
|
361
389
|
" [--thinking default|off|minimal|low|medium|high|xhigh|max]",
|
|
362
390
|
" journal saved sessions (default: last 3 days)",
|
|
363
|
-
" logdig status [N|all] [--json]
|
|
391
|
+
" logdig status [N|all] [--json] [--skip-today]",
|
|
392
|
+
" show journal coverage (default: last 3 days; read-only)",
|
|
364
393
|
" logdig pi-install | pi-uninstall add or remove /journal integration",
|
|
365
394
|
" logdig --version show the installed version",
|
|
366
395
|
"",
|
|
367
396
|
"Preview first: --dry-run shows dates, files, and cache hits. It never calls Pi",
|
|
368
397
|
"or writes files. Remove --dry-run when you're ready to summarize.",
|
|
398
|
+
"--skip-today selects N complete calendar days ending yesterday, or all past work.",
|
|
399
|
+
"Work periods with activity today are excluded entirely, including overnight work.",
|
|
369
400
|
"",
|
|
370
401
|
"Real backfill uses Pi's startup model and existing authentication. Tools,",
|
|
371
402
|
"extensions, project context, and session saving are disabled for model requests.",
|
|
@@ -428,5 +459,5 @@ async function main() {
|
|
|
428
459
|
|
|
429
460
|
main().catch((error) => {
|
|
430
461
|
console.error(`logdig: ${error.message}`);
|
|
431
|
-
process.exitCode = error.code
|
|
462
|
+
process.exitCode = ["LOGDIG_SETUP_CANCELLED", "LOGDIG_CONFIG_CANCELLED"].includes(error.code) ? 130 : 1;
|
|
432
463
|
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import { progressStatus } from "./cli-status.js";
|
|
3
|
+
|
|
4
|
+
function cleanText(value) {
|
|
5
|
+
return stripVTControlCharacters(String(value ?? "")).replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function fitLiveLine(line, columns) {
|
|
9
|
+
const limit = Math.max(0, columns - 1);
|
|
10
|
+
let width = 0;
|
|
11
|
+
let fitted = "";
|
|
12
|
+
for (const token of line.match(/\x1b\[[0-9;]*m|./gu) || []) {
|
|
13
|
+
if (token.startsWith("\x1b[")) {
|
|
14
|
+
fitted += token;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
// Conservatively allow two cells for non-ASCII text so panel rows never wrap.
|
|
18
|
+
const cells = token.codePointAt(0) > 127 ? 2 : 1;
|
|
19
|
+
if (width + cells > limit) break;
|
|
20
|
+
width += cells;
|
|
21
|
+
fitted += token;
|
|
22
|
+
}
|
|
23
|
+
return fitted + (line.includes("\x1b[") ? "\x1b[0m" : "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createBackfillProgress({ total, concurrency, dryRun = false, stream = process.stdout, term = process.env.TERM, icons, noColor }) {
|
|
27
|
+
const live = stream.isTTY && term !== "dumb" && !dryRun;
|
|
28
|
+
const statusOptions = { isTTY: stream.isTTY, term, icons, noColor, pad: true };
|
|
29
|
+
const sessions = Array.from({ length: total }, () => ({ lines: [], complete: false, failed: false }));
|
|
30
|
+
let nextResult = 0;
|
|
31
|
+
let completed = 0;
|
|
32
|
+
let panelRows = 0;
|
|
33
|
+
let refreshTimer;
|
|
34
|
+
let closed = false;
|
|
35
|
+
|
|
36
|
+
function clearPanel() {
|
|
37
|
+
if (!panelRows) return;
|
|
38
|
+
stream.write("\r" + "\x1b[1A\x1b[2K\r".repeat(panelRows));
|
|
39
|
+
panelRows = 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sessionLabel(event, status = event.status, elapsed = "") {
|
|
43
|
+
const position = `${String(event.index).padStart(String(total).length, "0")}/${total}`;
|
|
44
|
+
const timestamp = `${cleanText(event.date)}${event.time ? ` ${cleanText(event.time)}` : ""}`.padEnd(16);
|
|
45
|
+
return `[${position}] ${timestamp} · ${progressStatus(status, statusOptions)} · ${elapsed ? `${elapsed} · ` : ""}${cleanText(event.project) || "session"} (${cleanText(event.sessionId).slice(-8)})`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderPanel() {
|
|
49
|
+
if (!live || closed) return;
|
|
50
|
+
clearPanel();
|
|
51
|
+
const pending = sessions.slice(nextResult).filter((session) => session.event);
|
|
52
|
+
const waiting = pending.filter((session) => session.complete);
|
|
53
|
+
const active = pending.filter((session) => !session.complete);
|
|
54
|
+
if (active.length && !refreshTimer) {
|
|
55
|
+
refreshTimer = setInterval(renderPanel, 1000);
|
|
56
|
+
refreshTimer.unref();
|
|
57
|
+
} else if (!active.length && refreshTimer) {
|
|
58
|
+
clearInterval(refreshTimer);
|
|
59
|
+
refreshTimer = undefined;
|
|
60
|
+
}
|
|
61
|
+
if (!pending.length) return;
|
|
62
|
+
const queued = total - completed - active.length;
|
|
63
|
+
const overhead = 1 + Number(active.length > 0) + Number(waiting.length > 0);
|
|
64
|
+
const capacity = Math.max(0, Math.min(concurrency, (stream.rows || 24) - 2 - overhead));
|
|
65
|
+
const lines = [`Progress: ${completed}/${total} complete · ${active.length} active · ${queued} queued`];
|
|
66
|
+
if (active.length) {
|
|
67
|
+
const hidden = Math.max(0, active.length - capacity);
|
|
68
|
+
lines.push(`Active sessions (stage elapsed${hidden ? `; ${hidden} not shown` : ""}):`);
|
|
69
|
+
for (const session of active.slice(0, capacity)) {
|
|
70
|
+
const seconds = Math.max(0, Math.floor((Date.now() - session.stageStartedAt) / 1000));
|
|
71
|
+
const elapsed = seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
72
|
+
lines.push(sessionLabel(session.event, session.event.status, elapsed));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (waiting.length) {
|
|
76
|
+
const blocker = String(nextResult + 1).padStart(String(total).length, "0");
|
|
77
|
+
const failed = waiting.filter((session) => session.failed).length;
|
|
78
|
+
lines.push(`${waiting.length} finished session${waiting.length === 1 ? "" : "s"} waiting to print after #${blocker}.${failed ? ` ${failed} with errors.` : ""}`);
|
|
79
|
+
}
|
|
80
|
+
stream.write(lines.map((line) => fitLiveLine(line, stream.columns || 80)).join("\n") + "\n");
|
|
81
|
+
panelRows = lines.length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function onProgress(event) {
|
|
85
|
+
if (closed) return;
|
|
86
|
+
const session = sessions[event.index - 1];
|
|
87
|
+
const previous = session.event;
|
|
88
|
+
if (!previous || previous.phase !== event.phase || previous.blockId !== event.blockId || previous.date !== event.date || previous.time !== event.time) {
|
|
89
|
+
session.stageStartedAt = Date.now();
|
|
90
|
+
}
|
|
91
|
+
session.event = event;
|
|
92
|
+
if (event.phase === "error") session.failed = true;
|
|
93
|
+
if (["complete", "skipped", "error"].includes(event.phase)) {
|
|
94
|
+
const detail = event.error || event.reason;
|
|
95
|
+
session.lines.push(`${sessionLabel(event)}${event.prerequisite ? " · prerequisite" : ""}${detail ? `: ${cleanText(detail)}` : ""}`);
|
|
96
|
+
if (dryRun && event.dailyPath) {
|
|
97
|
+
session.lines.push(` Daily note: ${cleanText(event.dailyPath)}`, ` Full summary: ${cleanText(event.sessionPath)}`);
|
|
98
|
+
if (event.prerequisite) session.lines.push(" Includes this earlier block to establish the continuation link.");
|
|
99
|
+
}
|
|
100
|
+
} else if (!live && event.phase === "summarizing") {
|
|
101
|
+
stream.write(`Active ${sessionLabel(event)}\n`);
|
|
102
|
+
}
|
|
103
|
+
renderPanel();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function onSessionComplete({ index }) {
|
|
107
|
+
if (closed) return;
|
|
108
|
+
const session = sessions[index - 1];
|
|
109
|
+
session.complete = true;
|
|
110
|
+
completed++;
|
|
111
|
+
clearPanel();
|
|
112
|
+
if (!live && !dryRun && index - 1 > nextResult) {
|
|
113
|
+
const blocker = String(nextResult + 1).padStart(String(total).length, "0");
|
|
114
|
+
stream.write(`Finished ${sessionLabel(session.event, session.failed ? "FAILED" : "DONE")} (waiting to print after #${blocker}; processing continues)\n`);
|
|
115
|
+
}
|
|
116
|
+
while (sessions[nextResult]?.complete) {
|
|
117
|
+
const { lines } = sessions[nextResult];
|
|
118
|
+
if (lines.length) stream.write(lines.join("\n") + "\n");
|
|
119
|
+
lines.length = 0;
|
|
120
|
+
nextResult++;
|
|
121
|
+
}
|
|
122
|
+
renderPanel();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function close() {
|
|
126
|
+
if (closed) return;
|
|
127
|
+
clearPanel();
|
|
128
|
+
closed = true;
|
|
129
|
+
clearInterval(refreshTimer);
|
|
130
|
+
refreshTimer = undefined;
|
|
131
|
+
if (live) stream.removeListener("resize", renderPanel);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (live) stream.on("resize", renderPanel);
|
|
135
|
+
return { onProgress, onSessionComplete, close };
|
|
136
|
+
}
|
package/src/cli-status.js
CHANGED
|
@@ -7,6 +7,20 @@ const statuses = {
|
|
|
7
7
|
error: { text: "FIX", icon: "\uf00d", color: "31" },
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
const progressStatuses = {
|
|
11
|
+
CHECKING: { icon: "\uf002", color: "36" },
|
|
12
|
+
SUMMARIZING: { icon: "\uf110", color: "36" },
|
|
13
|
+
SAVED: { icon: "\uf00c", color: "32" },
|
|
14
|
+
UPDATED: { icon: "\uf040", color: "36" },
|
|
15
|
+
CURRENT: { icon: "\uf058", color: "32" },
|
|
16
|
+
DONE: { icon: "\uf00c", color: "32" },
|
|
17
|
+
SKIPPED: { icon: "\uf05e", color: "33" },
|
|
18
|
+
FAILED: { icon: "\uf00d", color: "31" },
|
|
19
|
+
PREVIEW: { icon: "\uf06e", color: "36" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const PROGRESS_STATUS_WIDTH = Math.max(...Object.keys(progressStatuses).map((status) => status.length));
|
|
23
|
+
|
|
10
24
|
export { STATUS_WIDTH };
|
|
11
25
|
|
|
12
26
|
function useIcons({
|
|
@@ -33,3 +47,14 @@ export function statusPrefix(status, options = {}) {
|
|
|
33
47
|
const icon = noColor ? marker.icon : `\u001b[${marker.color}m${marker.icon}\u001b[0m`;
|
|
34
48
|
return `${icon} `;
|
|
35
49
|
}
|
|
50
|
+
|
|
51
|
+
export function progressStatus(status, options = {}) {
|
|
52
|
+
const marker = progressStatuses[status];
|
|
53
|
+
if (!marker) throw new TypeError(`Unknown progress status: ${status}`);
|
|
54
|
+
const text = options.pad ? status.padEnd(PROGRESS_STATUS_WIDTH) : status;
|
|
55
|
+
if (!useIcons(options)) return text;
|
|
56
|
+
|
|
57
|
+
const noColor = options.noColor ?? process.env.NO_COLOR !== undefined;
|
|
58
|
+
const icon = noColor ? marker.icon : `\u001b[${marker.color}m${marker.icon}\u001b[0m`;
|
|
59
|
+
return `${icon} ${text}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
import { checkDirectory } from "./directories.js";
|
|
4
|
+
import { environmentOverrideFor, loadSettings, saveSettings, validateSettings } from "./settings.js";
|
|
5
|
+
import { statusPrefix } from "./cli-status.js";
|
|
6
|
+
|
|
7
|
+
const fields = [
|
|
8
|
+
{ key: "dailyDirectory", label: "Daily-notes folder", folder: "write",
|
|
9
|
+
hint: "The folder holding YYYY-MM-DD.md notes, not the vault root." },
|
|
10
|
+
{ key: "cacheDirectory", label: "Summary cache folder", folder: "write",
|
|
11
|
+
hint: "Keeps all three summary lengths. Put it inside your vault to browse them in Obsidian." },
|
|
12
|
+
{ key: "dailyHeader", label: "Journal heading",
|
|
13
|
+
hint: "One Markdown heading, including its # level, for example # Projects." },
|
|
14
|
+
{ key: "dailyHeaderAnchor", label: "Anchor heading", empty: "none",
|
|
15
|
+
hint: "Create the journal section after this heading's full section. Type none to append at the end. Existing sections stay in place." },
|
|
16
|
+
{ key: "dailySummary", label: "Daily summary length", choices: ["small", "medium", "large"],
|
|
17
|
+
hint: "Small: a few sentences. Medium: goal, progress, and next steps. Large: a short timeline." },
|
|
18
|
+
{ key: "timeZone", label: "Timezone",
|
|
19
|
+
hint: "Determines journal dates and times, for example Europe/Berlin or UTC." },
|
|
20
|
+
{ key: "sessionDirectory", label: "Pi session-history folder", folder: "read",
|
|
21
|
+
hint: "The folder containing saved Pi sessions. Missing folders are allowed." },
|
|
22
|
+
{ key: "piCommand", label: "Pi executable",
|
|
23
|
+
hint: "A command on your PATH, such as pi, or a full executable path." },
|
|
24
|
+
{ key: "model", label: "Summary model", empty: "default",
|
|
25
|
+
hint: "Use provider/model, or type default to remove the LogDig override. Changing the model can require new summaries." },
|
|
26
|
+
{ key: "thinkingLevel", label: "Thinking level", empty: "default",
|
|
27
|
+
choices: ["default", "off", "minimal", "low", "medium", "high", "xhigh", "max"],
|
|
28
|
+
hint: "More thinking may take longer and cost more. Model support varies. Default removes the LogDig override." },
|
|
29
|
+
{ key: "concurrency", label: "Maximum parallel sessions",
|
|
30
|
+
hint: "A positive integer. Use 1 for sequential processing or to reduce rate-limit pressure." },
|
|
31
|
+
{ key: "autoCapture", label: "Automatic capture", choices: ["yes", "no"],
|
|
32
|
+
hint: "Capture on Pi shutdown. Requires the Pi extension; sends selected, redacted history to your model, may incur charges, and can delay shutdown. Redaction is not a complete secret scanner." },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function displayValue(field, value) {
|
|
36
|
+
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
37
|
+
return String(value || field.empty || "not configured");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function editConfig(commandName = "logdig", { input = stdin, output = stdout, env = process.env } = {}) {
|
|
41
|
+
let settings = await loadSettings({ env, applyEnvironment: false });
|
|
42
|
+
const print = (text = "") => output.write(`${text}\n`);
|
|
43
|
+
const labelWidth = Math.max(...fields.map((field) => field.label.length));
|
|
44
|
+
const showMenu = () => {
|
|
45
|
+
print("\nLogDig settings");
|
|
46
|
+
print(`Settings: ${settings.filePath}`);
|
|
47
|
+
print("Values below are saved preferences (or defaults). Environment overrides take precedence.");
|
|
48
|
+
print();
|
|
49
|
+
fields.forEach((field, index) => {
|
|
50
|
+
const variable = environmentOverrideFor(field.key, env);
|
|
51
|
+
print(` ${String(index + 1).padStart(2)}. ${field.label.padEnd(labelWidth)} ${displayValue(field, settings[field.key])}`);
|
|
52
|
+
if (variable) print(` Override: ${variable}=${JSON.stringify(env[variable])} (saved changes will not override it)`);
|
|
53
|
+
});
|
|
54
|
+
print("\nChanges save immediately. No summaries or daily notes are written here.");
|
|
55
|
+
};
|
|
56
|
+
showMenu();
|
|
57
|
+
if (!settings.dailyDirectory || !settings.cacheDirectory) {
|
|
58
|
+
print(`\nRun '${commandName} init' first to configure your notes and cache folders.`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rl = createInterface({ input, output, terminal: Boolean(output.isTTY) && env.TERM !== "dumb" });
|
|
63
|
+
const answers = rl[Symbol.asyncIterator]();
|
|
64
|
+
let interrupted = false;
|
|
65
|
+
const cancel = () => { interrupted = true; rl.close(); };
|
|
66
|
+
rl.on("SIGINT", cancel);
|
|
67
|
+
process.once("SIGINT", cancel);
|
|
68
|
+
const checkInterrupted = () => {
|
|
69
|
+
if (!interrupted) return;
|
|
70
|
+
const error = new Error("Configuration cancelled. Previously saved changes are kept.");
|
|
71
|
+
error.code = "LOGDIG_CONFIG_CANCELLED";
|
|
72
|
+
throw error;
|
|
73
|
+
};
|
|
74
|
+
const ask = async (prompt) => {
|
|
75
|
+
rl.setPrompt(prompt);
|
|
76
|
+
rl.prompt();
|
|
77
|
+
const answer = await answers.next();
|
|
78
|
+
checkInterrupted();
|
|
79
|
+
return answer.done ? "q" : answer.value.trim();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
while (true) {
|
|
84
|
+
const selection = await ask("\nChoose a setting by number or name (q to quit): ");
|
|
85
|
+
if (selection.toLowerCase() === "q") break;
|
|
86
|
+
if (!selection) continue;
|
|
87
|
+
const matches = /^\d+$/.test(selection)
|
|
88
|
+
? fields.filter((_, index) => index + 1 === Number(selection))
|
|
89
|
+
: fields.filter((field) => `${field.label} ${field.key}`.toLowerCase().includes(selection.toLowerCase()));
|
|
90
|
+
if (matches.length !== 1) {
|
|
91
|
+
print(matches.length
|
|
92
|
+
? `Please be more specific: ${matches.map((field) => `${fields.indexOf(field) + 1}. ${field.label}`).join("; ")}.`
|
|
93
|
+
: `Choose a number from 1 to ${fields.length}, a setting name, or q.`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const field = matches[0];
|
|
97
|
+
print(`\n${field.label}`);
|
|
98
|
+
print(field.hint);
|
|
99
|
+
if (field.choices) print(`Available values: ${field.choices.join(", ")}`);
|
|
100
|
+
print("Enter keeps the current value. q quits without changing this field.");
|
|
101
|
+
while (true) {
|
|
102
|
+
const value = await ask(`New value [${displayValue(field, settings[field.key])}]: `);
|
|
103
|
+
if (value.toLowerCase() === "q") return;
|
|
104
|
+
if (!value) {
|
|
105
|
+
print("Kept the current value.");
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
let nextSettings;
|
|
109
|
+
try {
|
|
110
|
+
let parsed = value;
|
|
111
|
+
if (field.key === "autoCapture") {
|
|
112
|
+
if (!/^(yes|no|y|n)$/i.test(value)) throw new Error("Please enter yes or no");
|
|
113
|
+
parsed = /^(yes|y)$/i.test(value);
|
|
114
|
+
} else if (field.key === "model" && value.toLowerCase() === "default") parsed = undefined;
|
|
115
|
+
else if (field.key === "dailyHeaderAnchor" && value.toLowerCase() === "none") parsed = "";
|
|
116
|
+
nextSettings = validateSettings({ ...settings, [field.key]: parsed });
|
|
117
|
+
if (field.folder) await checkDirectory(nextSettings[field.key], { writable: field.folder === "write", allowMissing: true });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
print(` ${error.message}. Let's try that again.`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
checkInterrupted();
|
|
123
|
+
await saveSettings(nextSettings, settings.filePath);
|
|
124
|
+
settings = nextSettings;
|
|
125
|
+
print(`\n${statusPrefix("ok", { isTTY: output.isTTY, term: env.TERM, icons: env.LOGDIG_ICONS !== "0", noColor: env.NO_COLOR !== undefined })}${field.label} saved: ${displayValue(field, settings[field.key])}`);
|
|
126
|
+
if (field.key === "autoCapture" && settings.autoCapture) print(`If the Pi extension is not installed, run '${commandName} pi-install'.`);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
showMenu();
|
|
130
|
+
}
|
|
131
|
+
} finally {
|
|
132
|
+
process.removeListener("SIGINT", cancel);
|
|
133
|
+
rl.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { JOURNAL_SYSTEM_PROMPT, summaryCachePolicy } from "./journal.js";
|
|
4
4
|
import { collectSessions, parseBackfillArgument, writeSessions } from "./session-runner.js";
|
|
5
5
|
import { loadSettings } from "./settings.js";
|
|
6
|
+
import { progressStatus } from "./cli-status.js";
|
|
6
7
|
|
|
7
8
|
function requireJournalSettings(settings) {
|
|
8
9
|
if (!settings.cacheDirectory) throw new Error("Run 'logdig init' or set PI_JOURNAL_DIR to a LogDig cache folder");
|
|
@@ -68,10 +69,11 @@ function notify(ctx, message, level = "info") {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
function journalProgress(ctx) {
|
|
71
|
-
return ({ index, total, project,
|
|
72
|
-
const
|
|
72
|
+
return ({ index, total, sessionId, project, date, time, status }) => {
|
|
73
|
+
const label = `${date ? `${date}${time ? ` ${time}` : ""} · ` : ""}${project || sessionId}`;
|
|
74
|
+
const message = `LogDig [${index}/${total}] ${label} · ${progressStatus(status || "CHECKING")}`;
|
|
73
75
|
if (ctx.hasUI) ctx.ui.setStatus?.("logdig", message);
|
|
74
|
-
else
|
|
76
|
+
else console.log(message);
|
|
75
77
|
};
|
|
76
78
|
}
|
|
77
79
|
|
package/src/journal.js
CHANGED
|
@@ -391,7 +391,7 @@ function isProjectHeading(line, targetLevel) {
|
|
|
391
391
|
function addEntryAt(markdown, offset, entry) {
|
|
392
392
|
const before = markdown.slice(0, offset).replace(/\n{3,}$/, "\n\n");
|
|
393
393
|
const after = markdown.slice(offset);
|
|
394
|
-
const separator =
|
|
394
|
+
const separator = /\r?\n\r?\n$/.test(before) ? "" : before.endsWith("\n") ? "\n" : "\n\n";
|
|
395
395
|
const suffix = after ? "\n\n" : "\n";
|
|
396
396
|
return `${before}${separator}${entry}${suffix}${after}`;
|
|
397
397
|
}
|
|
@@ -503,7 +503,7 @@ function removeEmptyProjectGroups(markdown, heading) {
|
|
|
503
503
|
return removeMarkdownRanges(markdown, emptyGroups);
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
-
function appendUnderProject(markdown, heading, entry) {
|
|
506
|
+
function appendUnderProject(markdown, heading, entry, anchorHeading) {
|
|
507
507
|
const targetLevel = headingLevel(heading);
|
|
508
508
|
const project = entry.project.replace(/[\\`*_[\]<>|&#\r\n]/g, (character) =>
|
|
509
509
|
/[\r\n]/.test(character) ? " " : `&#${character.charCodeAt(0)};`);
|
|
@@ -568,8 +568,24 @@ function appendUnderProject(markdown, heading, entry) {
|
|
|
568
568
|
|
|
569
569
|
const text = dailyEntryText(entry, heading);
|
|
570
570
|
if (sectionStart === -1) {
|
|
571
|
-
|
|
572
|
-
|
|
571
|
+
let anchorFound = false;
|
|
572
|
+
let offset = markdown.length;
|
|
573
|
+
const anchorLevel = headingLevel(anchorHeading || "");
|
|
574
|
+
if (anchorHeading) {
|
|
575
|
+
for (const line of markdownLines(markdown)) {
|
|
576
|
+
if (line.blocked) continue;
|
|
577
|
+
if (!anchorFound) {
|
|
578
|
+
if (line.text.trimEnd() === anchorHeading) anchorFound = true;
|
|
579
|
+
} else if (headingLevel(line.text) <= anchorLevel) {
|
|
580
|
+
offset = line.start;
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const section = `${heading}\n\n${projectHeading}\n\n${text}`;
|
|
586
|
+
if (offset < markdown.length) return addEntryAt(markdown, offset, section);
|
|
587
|
+
const separator = !markdown || /\r?\n\r?\n$/.test(markdown) ? "" : markdown.endsWith("\n") ? "\n" : "\n\n";
|
|
588
|
+
return `${markdown}${separator}${section}\n`;
|
|
573
589
|
}
|
|
574
590
|
|
|
575
591
|
if (projectStart === -1) return addEntryAt(markdown, sectionEnd, `${projectHeading}\n\n${text}`);
|
|
@@ -668,7 +684,7 @@ async function updateEntrySnapshot(entryPath, entry, createIfMissing) {
|
|
|
668
684
|
if (enriched !== snapshot) await writeAtomically(entryPath, enriched);
|
|
669
685
|
}
|
|
670
686
|
|
|
671
|
-
async function updateSessionDailyNotes(heading, entry, inspection) {
|
|
687
|
+
async function updateSessionDailyNotes(heading, entry, inspection, anchorHeading) {
|
|
672
688
|
const { dailyPath, existing, sessionBlocks, sessionDocuments } = inspection;
|
|
673
689
|
const customSummaries = [...new Set(sessionBlocks
|
|
674
690
|
.filter((block) => !isUneditedEntry(block))
|
|
@@ -686,12 +702,12 @@ async function updateSessionDailyNotes(heading, entry, inspection) {
|
|
|
686
702
|
const updatedMarkdown = appendUnderProject(markdown, heading, {
|
|
687
703
|
...entry,
|
|
688
704
|
...(customSummaries.length ? { summaryOverride: customSummaries.join("\n\n") } : {}),
|
|
689
|
-
});
|
|
705
|
+
}, anchorHeading);
|
|
690
706
|
await writeAtomically(dailyPath, updatedMarkdown);
|
|
691
707
|
for (const [filePath, contents] of cleanedDocuments) await writeAtomically(filePath, contents);
|
|
692
708
|
}
|
|
693
709
|
|
|
694
|
-
export async function appendDailyEntry(dailyDirectory, heading, entry, journaledEntries) {
|
|
710
|
+
export async function appendDailyEntry(dailyDirectory, heading, entry, journaledEntries, anchorHeading = "") {
|
|
695
711
|
const inspection = await inspectDailyEntry(dailyDirectory, heading, entry, journaledEntries);
|
|
696
712
|
const { dailyPath, appended, updated } = inspection;
|
|
697
713
|
const entryPath = path.join(path.dirname(entry.sessionPath), "..", "Entries", `${dailyEntryId(entry, heading)}.md`);
|
|
@@ -701,6 +717,6 @@ export async function appendDailyEntry(dailyDirectory, heading, entry, journaled
|
|
|
701
717
|
}
|
|
702
718
|
|
|
703
719
|
await updateEntrySnapshot(entryPath, entry, true);
|
|
704
|
-
await updateSessionDailyNotes(heading, entry, inspection);
|
|
720
|
+
await updateSessionDailyNotes(heading, entry, inspection, anchorHeading);
|
|
705
721
|
return { dailyPath, entryPath, appended, updated: updated || inspection.sessionBlocks.length > 0, legacyEntry: inspection.legacyEntry };
|
|
706
722
|
}
|
package/src/session-runner.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile, readdir, stat } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { appendDailyEntry, dailyEntryId, inspectDailyEntry, inspectSessionSummary, listJournaledSessions, saveSessionSummary } from "./journal.js";
|
|
4
4
|
import { sessionFromJsonl, sessionMetrics } from "./transcript.js";
|
|
5
|
+
import { DEFAULT_CONCURRENCY } from "./settings.js";
|
|
5
6
|
import { assignLegacyBlocks, blockInRange, workBlocksForSession } from "./work-blocks.js";
|
|
6
7
|
|
|
7
8
|
function localDate(timestamp, timeZone) {
|
|
@@ -66,13 +67,15 @@ async function findSessionFiles(root) {
|
|
|
66
67
|
return { files, warnings };
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
export async function collectSessions({ sessionDirectory, timeZone, days, currentSession }) {
|
|
70
|
+
export async function collectSessions({ sessionDirectory, timeZone, days, currentSession, skipToday = false }) {
|
|
70
71
|
const { files, warnings } = await findSessionFiles(sessionDirectory);
|
|
71
72
|
const currentId = currentSession?.header.id;
|
|
72
73
|
const currentPath = currentSession?.sourcePath;
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const today = localDate(now, timeZone);
|
|
76
|
+
const lastDate = skipToday ? shiftDate(today, -1) : today;
|
|
77
|
+
const firstDate = days ? shiftDate(lastDate, 1 - days) : undefined;
|
|
78
|
+
const modifiedAfter = days ? now - (days + 1 + Number(skipToday)) * 86400000 : undefined;
|
|
76
79
|
const sessions = [];
|
|
77
80
|
const seen = new Set();
|
|
78
81
|
|
|
@@ -93,7 +96,7 @@ export async function collectSessions({ sessionDirectory, timeZone, days, curren
|
|
|
93
96
|
|
|
94
97
|
if (currentSession && !seen.has(currentSession.header.id)) sessions.push(currentSession);
|
|
95
98
|
sessions.sort((left, right) => sessionEndTime(left) - sessionEndTime(right) || left.header.id.localeCompare(right.header.id));
|
|
96
|
-
return { sessions, warnings, firstDate, lastDate:
|
|
99
|
+
return { sessions, warnings, firstDate, lastDate, ...(skipToday ? { skipToday: true } : {}) };
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
function selectedBlockIndexes(blocks, versions, range) {
|
|
@@ -128,13 +131,22 @@ export async function writeSessions(modelClient, sessions, settings, range = {})
|
|
|
128
131
|
let entriesUpdated = 0;
|
|
129
132
|
let entriesSkipped = 0;
|
|
130
133
|
let sessionsSkipped = 0;
|
|
134
|
+
let blocksExcludedToday = 0;
|
|
131
135
|
const dates = new Set();
|
|
132
136
|
const dailyPaths = new Set();
|
|
133
|
-
const
|
|
134
|
-
const sessionResults = [];
|
|
137
|
+
const results = sessions.map(() => ({ errors: [], sessionResults: [] }));
|
|
135
138
|
const journaledEntries = range.journaledEntries || await listJournaledSessions(settings.cacheDirectory);
|
|
139
|
+
let dailyWrites = Promise.resolve();
|
|
140
|
+
const writeDailyEntry = (entry) => {
|
|
141
|
+
// Entry migration can touch multiple dates, so serialize all daily-note read/modify/writes.
|
|
142
|
+
const pending = dailyWrites.then(() => appendDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry, journaledEntries, settings.dailyHeaderAnchor));
|
|
143
|
+
// The caller reports a failed write; it must not poison later queued writes.
|
|
144
|
+
dailyWrites = pending.catch(() => {});
|
|
145
|
+
return pending;
|
|
146
|
+
};
|
|
136
147
|
|
|
137
|
-
|
|
148
|
+
async function writeSession(session, index) {
|
|
149
|
+
const { errors, sessionResults } = results[index];
|
|
138
150
|
const progress = { index: index + 1, total: sessions.length, sessionId: session.header.id };
|
|
139
151
|
let blocks;
|
|
140
152
|
try {
|
|
@@ -142,37 +154,49 @@ export async function writeSessions(modelClient, sessions, settings, range = {})
|
|
|
142
154
|
} catch (error) {
|
|
143
155
|
errors.push(`${session.header.id}: ${error.message}`);
|
|
144
156
|
sessionResults.push({ sessionId: session.header.id, status: "error", error: error.message });
|
|
145
|
-
range.onProgress?.({ ...progress, phase: "error", error: error.message });
|
|
146
|
-
|
|
157
|
+
range.onProgress?.({ ...progress, phase: "error", status: "FAILED", error: error.message });
|
|
158
|
+
return;
|
|
147
159
|
}
|
|
148
160
|
const versions = journaledEntries.get(session.header.id) || [];
|
|
149
161
|
journaledEntries.set(session.header.id, versions);
|
|
150
162
|
assignLegacyBlocks(versions, blocks);
|
|
163
|
+
const excludedToday = range.skipToday ? blocks.filter((block) => block.activityDates.some((date) => date > range.lastDate)).length : 0;
|
|
164
|
+
blocksExcludedToday += excludedToday;
|
|
151
165
|
const selected = selectedBlockIndexes(blocks, versions, range);
|
|
152
166
|
if (!selected.size) {
|
|
153
167
|
sessionsSkipped++;
|
|
154
|
-
const reason = blocks.length ? "outside date range" : "no journalable events";
|
|
168
|
+
const reason = excludedToday ? "work periods active today (--skip-today)" : blocks.length ? "outside date range" : "no journalable events";
|
|
169
|
+
const latestBlock = blocks.at(-1);
|
|
155
170
|
sessionResults.push({ sessionId: session.header.id, status: "skipped", reason });
|
|
156
|
-
range.onProgress?.({
|
|
157
|
-
|
|
171
|
+
range.onProgress?.({
|
|
172
|
+
...progress,
|
|
173
|
+
...(latestBlock && { project: latestBlock.project, date: latestBlock.date, time: latestBlock.time }),
|
|
174
|
+
phase: "skipped",
|
|
175
|
+
status: "SKIPPED",
|
|
176
|
+
reason,
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
158
179
|
}
|
|
159
180
|
|
|
160
181
|
const plannedSnapshots = new Map();
|
|
161
182
|
for (const [blockIndex, block] of blocks.entries()) {
|
|
162
183
|
if (!selected.has(blockIndex)) continue;
|
|
163
|
-
const blockProgress = {
|
|
184
|
+
const blockProgress = {
|
|
185
|
+
...progress, blockId: block.blockId, blockIndex: blockIndex + 1, blockTotal: blocks.length,
|
|
186
|
+
project: block.project, date: block.date, time: block.time, prerequisite: !blockInRange(block, range),
|
|
187
|
+
};
|
|
164
188
|
try {
|
|
165
189
|
const previous = blocks[blockIndex - 1];
|
|
166
190
|
const continuationOf = previous
|
|
167
191
|
? plannedSnapshots.get(previous.blockId) || await precedingSnapshot(modelClient, previous, versions, settings)
|
|
168
192
|
: undefined;
|
|
169
193
|
const journalBlock = { ...block, continuationOf };
|
|
170
|
-
const prerequisite =
|
|
171
|
-
range.onProgress?.({ ...blockProgress, phase: "checking", status: "
|
|
194
|
+
const { prerequisite } = blockProgress;
|
|
195
|
+
range.onProgress?.({ ...blockProgress, phase: "checking", status: "CHECKING" });
|
|
172
196
|
const cached = range.dryRun
|
|
173
197
|
? await inspectSessionSummary(modelClient, settings.cacheDirectory, journalBlock)
|
|
174
198
|
: await saveSessionSummary(modelClient, settings.cacheDirectory, journalBlock, {
|
|
175
|
-
onGenerate: () => range.onProgress?.({ ...blockProgress, phase: "summarizing", status: "
|
|
199
|
+
onGenerate: () => range.onProgress?.({ ...blockProgress, phase: "summarizing", status: "SUMMARIZING" }),
|
|
176
200
|
});
|
|
177
201
|
if (cached.reused) summariesReused++;
|
|
178
202
|
else summariesCreated++;
|
|
@@ -194,7 +218,7 @@ export async function writeSessions(modelClient, sessions, settings, range = {})
|
|
|
194
218
|
};
|
|
195
219
|
const dailyEntry = range.dryRun
|
|
196
220
|
? await inspectDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry, journaledEntries)
|
|
197
|
-
: await
|
|
221
|
+
: await writeDailyEntry(entry);
|
|
198
222
|
if (dailyEntry.updated) entriesUpdated++;
|
|
199
223
|
else if (dailyEntry.appended) entriesAppended++;
|
|
200
224
|
else entriesSkipped++;
|
|
@@ -230,19 +254,32 @@ export async function writeSessions(modelClient, sessions, settings, range = {})
|
|
|
230
254
|
prerequisite,
|
|
231
255
|
sessionPath: cached.sessionPath,
|
|
232
256
|
dailyPath: dailyEntry.dailyPath,
|
|
233
|
-
status:
|
|
234
|
-
? `${cached.reused ? "would reuse summary" : "would summarize with Pi"}, ${dailyEntry.updated ? "would update entry" : dailyEntry.appended ? "would append entry" : "entry already present"}`
|
|
235
|
-
: `${cached.reused ? "summary reused" : "summary created"}, ${dailyEntry.updated ? "entry updated" : dailyEntry.appended ? "entry appended" : "entry already present"}`) + (prerequisite ? " (earlier block needed for continuation link)" : ""),
|
|
257
|
+
status: range.dryRun ? "PREVIEW" : dailyEntry.updated ? "UPDATED" : dailyEntry.appended ? "SAVED" : "CURRENT",
|
|
236
258
|
});
|
|
237
259
|
} catch (error) {
|
|
238
260
|
errors.push(`${session.header.id}: ${block.date} ${block.time}: ${error.message}`);
|
|
239
261
|
sessionResults.push({ sessionId: session.header.id, blockId: block.blockId, status: "error", error: error.message });
|
|
240
|
-
range.onProgress?.({ ...blockProgress, phase: "error", error: error.message });
|
|
262
|
+
range.onProgress?.({ ...blockProgress, phase: "error", status: "FAILED", error: error.message });
|
|
241
263
|
}
|
|
242
264
|
}
|
|
243
265
|
}
|
|
244
266
|
|
|
245
|
-
|
|
267
|
+
let nextIndex = 0;
|
|
268
|
+
const concurrency = Math.min(settings.concurrency ?? DEFAULT_CONCURRENCY, sessions.length);
|
|
269
|
+
await Promise.all(Array.from({ length: concurrency }, async () => {
|
|
270
|
+
while (nextIndex < sessions.length) {
|
|
271
|
+
const index = nextIndex++;
|
|
272
|
+
await writeSession(sessions[index], index);
|
|
273
|
+
range.onSessionComplete?.({ index: index + 1, total: sessions.length, sessionId: sessions[index].header.id });
|
|
274
|
+
}
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
summariesCreated, summariesReused, entriesAppended, entriesUpdated, entriesSkipped, sessionsSkipped, blocksExcludedToday,
|
|
279
|
+
dates: [...dates].sort(), dailyPaths: [...dailyPaths].sort(),
|
|
280
|
+
errors: results.flatMap((result) => result.errors),
|
|
281
|
+
sessionResults: results.flatMap((result) => result.sessionResults),
|
|
282
|
+
};
|
|
246
283
|
}
|
|
247
284
|
|
|
248
285
|
export function parseBackfillArgument(argument = "", command = "backfill") {
|
package/src/settings.js
CHANGED
|
@@ -3,10 +3,13 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
|
|
6
|
+
export const DEFAULT_CONCURRENCY = 4;
|
|
7
|
+
|
|
6
8
|
const ENVIRONMENT_SETTINGS = [
|
|
7
9
|
["PI_JOURNAL_DIR", "cacheDirectory"],
|
|
8
10
|
["PI_JOURNAL_DAILY_DIR", "dailyDirectory"],
|
|
9
11
|
["PI_JOURNAL_DAILY_HEADER", "dailyHeader"],
|
|
12
|
+
["PI_JOURNAL_DAILY_HEADER_ANCHOR", "dailyHeaderAnchor"],
|
|
10
13
|
["PI_JOURNAL_DAILY_SUMMARY", "dailySummary"],
|
|
11
14
|
["PI_JOURNAL_TIMEZONE", "timeZone"],
|
|
12
15
|
["PI_JOURNAL_MODEL", "model"],
|
|
@@ -40,11 +43,13 @@ function defaults({ env = process.env, home = homedir() } = {}) {
|
|
|
40
43
|
cacheDirectory: undefined,
|
|
41
44
|
dailyDirectory: undefined,
|
|
42
45
|
dailyHeader: "# Projects",
|
|
46
|
+
dailyHeaderAnchor: "",
|
|
43
47
|
dailySummary: "small",
|
|
44
48
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
45
49
|
sessionDirectory: path.join(expandPath(agentDirectory, home), "sessions"),
|
|
46
50
|
piCommand: "pi",
|
|
47
|
-
|
|
51
|
+
concurrency: DEFAULT_CONCURRENCY,
|
|
52
|
+
autoCapture: false,
|
|
48
53
|
};
|
|
49
54
|
}
|
|
50
55
|
|
|
@@ -64,6 +69,14 @@ export function validateSettings(settings, { requirePaths = false, home = homedi
|
|
|
64
69
|
if (!/^#{1,6}\s+[^\r\n]+$/.test(settings.dailyHeader)) {
|
|
65
70
|
throw new Error("dailyHeader must be one Markdown heading, such as '# Projects'");
|
|
66
71
|
}
|
|
72
|
+
settings.dailyHeaderAnchor ??= "";
|
|
73
|
+
if (typeof settings.dailyHeaderAnchor !== "string") {
|
|
74
|
+
throw new Error("dailyHeaderAnchor must be blank or one Markdown heading, such as '# Log'");
|
|
75
|
+
}
|
|
76
|
+
settings.dailyHeaderAnchor = settings.dailyHeaderAnchor.trim();
|
|
77
|
+
if (settings.dailyHeaderAnchor && !/^#{1,6}\s+[^\r\n]+$/.test(settings.dailyHeaderAnchor)) {
|
|
78
|
+
throw new Error("dailyHeaderAnchor must be blank or one Markdown heading, such as '# Log'");
|
|
79
|
+
}
|
|
67
80
|
if (typeof settings.timeZone !== "string" || !settings.timeZone.trim()) throw new Error("timeZone must be a valid timezone");
|
|
68
81
|
try {
|
|
69
82
|
new Intl.DateTimeFormat("en", { timeZone: settings.timeZone });
|
|
@@ -94,6 +107,14 @@ export function validateSettings(settings, { requirePaths = false, home = homedi
|
|
|
94
107
|
}
|
|
95
108
|
settings.thinkingLevel = level === "default" ? undefined : level;
|
|
96
109
|
}
|
|
110
|
+
if (settings.concurrency !== undefined) {
|
|
111
|
+
if (typeof settings.concurrency === "string" && /^\d+$/.test(settings.concurrency.trim())) {
|
|
112
|
+
settings.concurrency = Number(settings.concurrency.trim());
|
|
113
|
+
}
|
|
114
|
+
if (!Number.isSafeInteger(settings.concurrency) || settings.concurrency < 1) {
|
|
115
|
+
throw new Error("concurrency must be a positive integer (1 runs sessions sequentially)");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
97
118
|
if (settings.autoCapture !== undefined && typeof settings.autoCapture !== "boolean") {
|
|
98
119
|
throw new Error("autoCapture must be a boolean");
|
|
99
120
|
}
|
|
@@ -111,7 +132,13 @@ export function validateSettings(settings, { requirePaths = false, home = homedi
|
|
|
111
132
|
|
|
112
133
|
export function environmentOverrides(env = process.env) {
|
|
113
134
|
return [...ENVIRONMENT_SETTINGS.map(([variable]) => variable), "PI_JOURNAL_AUTO"]
|
|
114
|
-
.filter((variable) => env[variable] !== undefined && (env[variable] !== "" || variable === "PI_JOURNAL_AUTO"));
|
|
135
|
+
.filter((variable) => env[variable] !== undefined && (env[variable] !== "" || variable === "PI_JOURNAL_AUTO" || variable === "PI_JOURNAL_DAILY_HEADER_ANCHOR"));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function environmentOverrideFor(key, env = process.env) {
|
|
139
|
+
const variable = [...ENVIRONMENT_SETTINGS, ["PI_JOURNAL_AUTO", "autoCapture"]]
|
|
140
|
+
.find(([, setting]) => setting === key)?.[0];
|
|
141
|
+
return environmentOverrides(env).includes(variable) ? variable : undefined;
|
|
115
142
|
}
|
|
116
143
|
|
|
117
144
|
export async function loadSettings(options = {}) {
|
|
@@ -133,10 +160,12 @@ export async function loadSettings(options = {}) {
|
|
|
133
160
|
}
|
|
134
161
|
|
|
135
162
|
const settings = { ...defaults({ env, home }), ...stored };
|
|
136
|
-
|
|
137
|
-
|
|
163
|
+
if (options.applyEnvironment !== false) {
|
|
164
|
+
for (const [variable, key] of ENVIRONMENT_SETTINGS) {
|
|
165
|
+
if (env[variable] !== undefined && (env[variable] !== "" || key === "dailyHeaderAnchor")) settings[key] = env[variable];
|
|
166
|
+
}
|
|
167
|
+
if (env.PI_JOURNAL_AUTO !== undefined) settings.autoCapture = env.PI_JOURNAL_AUTO === "1" || env.PI_JOURNAL_AUTO === "true";
|
|
138
168
|
}
|
|
139
|
-
if (env.PI_JOURNAL_AUTO !== undefined) settings.autoCapture = env.PI_JOURNAL_AUTO === "1" || env.PI_JOURNAL_AUTO === "true";
|
|
140
169
|
validateSettings(settings);
|
|
141
170
|
return { ...settings, filePath, configured };
|
|
142
171
|
}
|
|
@@ -147,10 +176,12 @@ export async function saveSettings(settings, filePath = settings.filePath || set
|
|
|
147
176
|
cacheDirectory: settings.cacheDirectory,
|
|
148
177
|
dailyDirectory: settings.dailyDirectory,
|
|
149
178
|
dailyHeader: settings.dailyHeader || "# Projects",
|
|
179
|
+
dailyHeaderAnchor: settings.dailyHeaderAnchor ?? "",
|
|
150
180
|
dailySummary: settings.dailySummary || "small",
|
|
151
181
|
timeZone: settings.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
152
182
|
sessionDirectory: settings.sessionDirectory,
|
|
153
183
|
piCommand: settings.piCommand || "pi",
|
|
184
|
+
concurrency: settings.concurrency ?? DEFAULT_CONCURRENCY,
|
|
154
185
|
autoCapture: settings.autoCapture === true,
|
|
155
186
|
...(settings.model ? { model: settings.model } : {}),
|
|
156
187
|
...(settings.thinkingLevel !== undefined ? { thinkingLevel: settings.thinkingLevel } : {}),
|
package/src/setup.js
CHANGED
|
@@ -69,6 +69,9 @@ export async function configureLogDig(settings, commandName = "logdig") {
|
|
|
69
69
|
(value) => folder("cacheDirectory", value, true));
|
|
70
70
|
console.log("Entries are grouped by project, then by time, under this heading. Use # Projects to keep your personal log separate.");
|
|
71
71
|
const dailyHeader = await ask("Heading for journal entries", settings.dailyHeader, (value) => field("dailyHeader", value));
|
|
72
|
+
console.log("Create that section after an anchor's full section, before the next same-level or higher-level heading. Blank or missing anchors append at the end; existing sections stay in place.");
|
|
73
|
+
const dailyHeaderAnchor = await ask("Anchor heading (e.g. # Log), or 'none'", settings.dailyHeaderAnchor || "none",
|
|
74
|
+
(value) => field("dailyHeaderAnchor", value.toLowerCase() === "none" ? "" : value));
|
|
72
75
|
console.log("Small: a few sentences. Medium: goal, progress, and next steps. Large: a short timeline.");
|
|
73
76
|
const dailySummary = await ask("Daily summary (small/medium/large)", settings.dailySummary, (value) => field("dailySummary", value));
|
|
74
77
|
const timeZone = await ask("Timezone", settings.timeZone, (value) => field("timeZone", value));
|
|
@@ -77,8 +80,9 @@ export async function configureLogDig(settings, commandName = "logdig") {
|
|
|
77
80
|
console.log(`History: ${settings.sessionDirectory}`);
|
|
78
81
|
console.log(`Model: ${settings.model || "Pi's default (your active model for /journal)"}`);
|
|
79
82
|
console.log(`Thinking: ${settings.thinkingLevel || "default (no LogDig override)"}`);
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
console.log(`Parallel sessions: ${settings.concurrency}`);
|
|
84
|
+
const advanced = await ask("Change history folder, Pi executable, model, thinking level, or concurrency? (yes/no)", "no", yesNo);
|
|
85
|
+
let { sessionDirectory, piCommand, model, thinkingLevel, concurrency } = settings;
|
|
82
86
|
if (advanced) {
|
|
83
87
|
sessionDirectory = await ask("Pi session-history folder", sessionDirectory,
|
|
84
88
|
(value) => folder("sessionDirectory", value, false));
|
|
@@ -88,6 +92,8 @@ export async function configureLogDig(settings, commandName = "logdig") {
|
|
|
88
92
|
console.log("More thinking can help distinguish attempts from outcomes, but takes longer and may cost more. Support depends on the model.");
|
|
89
93
|
thinkingLevel = await ask("Thinking level (default/off/minimal/low/medium/high/xhigh/max)", thinkingLevel || "default",
|
|
90
94
|
(value) => field("thinkingLevel", value));
|
|
95
|
+
console.log("Independent sessions run in parallel. Use 1 for sequential processing or to reduce provider rate-limit pressure.");
|
|
96
|
+
concurrency = await ask("Maximum parallel sessions", concurrency, (value) => field("concurrency", value));
|
|
91
97
|
}
|
|
92
98
|
console.log("Summarizing sends selected, redacted history to your Pi model. Provider charges may apply.");
|
|
93
99
|
console.log("Common secrets are redacted, but this is not a complete secret scanner. Pi keeps your credentials.");
|
|
@@ -95,18 +101,20 @@ export async function configureLogDig(settings, commandName = "logdig") {
|
|
|
95
101
|
const autoCapture = await ask("Capture on Pi shutdown? (yes/no)", settings.autoCapture ? "yes" : "no", yesNo);
|
|
96
102
|
installExtension = await ask("Install the Pi extension for /journal? (yes/no)", "no", yesNo);
|
|
97
103
|
|
|
98
|
-
nextSettings = { ...settings, cacheDirectory, dailyDirectory, dailyHeader, dailySummary, timeZone, sessionDirectory, piCommand, model, thinkingLevel, autoCapture };
|
|
104
|
+
nextSettings = { ...settings, cacheDirectory, dailyDirectory, dailyHeader, dailyHeaderAnchor, dailySummary, timeZone, sessionDirectory, piCommand, model, thinkingLevel, concurrency, autoCapture };
|
|
99
105
|
console.log([
|
|
100
106
|
"",
|
|
101
107
|
"Ready to save",
|
|
102
108
|
` Daily notes: ${dailyDirectory}/YYYY-MM-DD.md`,
|
|
103
109
|
` Heading: ${dailyHeader}`,
|
|
110
|
+
` Anchor: ${dailyHeaderAnchor || "none (append at end)"}`,
|
|
104
111
|
` Summary: ${dailySummary}, in ${timeZone}`,
|
|
105
112
|
` Cache: ${path.join(cacheDirectory, "Sessions")}`,
|
|
106
113
|
` History: ${sessionDirectory}`,
|
|
107
114
|
` Pi: ${piCommand}`,
|
|
108
115
|
` Model: ${model || "Pi default"}`,
|
|
109
116
|
` Thinking: ${thinkingLevel || "default (no LogDig override)"}`,
|
|
117
|
+
` Parallel sessions: ${concurrency}`,
|
|
110
118
|
` Auto-capture: ${autoCapture ? "on" : "off"}`,
|
|
111
119
|
` Extension: ${installExtension ? "install now" : "leave unchanged"}`,
|
|
112
120
|
"Missing note and cache folders will be created on your first real backfill.",
|
package/src/transcript.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
|
|
3
4
|
const READ_TOOLS = new Set([
|
|
@@ -248,11 +249,80 @@ function eventFor(entry, formatter, toolCalls) {
|
|
|
248
249
|
return undefined;
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
const PROJECT_NAME_CACHE = new Map();
|
|
253
|
+
|
|
254
|
+
function cleanProjectName(name) {
|
|
255
|
+
return name.replace(/^\d{4}-\d{2}-\d{2}-(?=.)/, "");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function commonGitDirectory(gitDir) {
|
|
259
|
+
try {
|
|
260
|
+
const commonDir = readFileSync(path.join(gitDir, "commondir"), "utf8").trim();
|
|
261
|
+
return commonDir ? path.resolve(gitDir, commonDir) : undefined;
|
|
262
|
+
} catch {
|
|
263
|
+
const segments = gitDir.split(path.sep);
|
|
264
|
+
const worktreesIndex = segments.lastIndexOf("worktrees");
|
|
265
|
+
return worktreesIndex >= 0 ? segments.slice(0, worktreesIndex).join(path.sep) : undefined;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function projectNameFromGitMarker(directory) {
|
|
270
|
+
try {
|
|
271
|
+
const gitMarker = path.join(directory, ".git");
|
|
272
|
+
const markerStat = statSync(gitMarker);
|
|
273
|
+
if (markerStat.isDirectory()) return cleanProjectName(path.basename(directory)) || undefined;
|
|
274
|
+
if (!markerStat.isFile()) return undefined;
|
|
275
|
+
|
|
276
|
+
const gitDirMatch = readFileSync(gitMarker, "utf8").match(/^gitdir:\s*(.+?)\s*$/m);
|
|
277
|
+
if (!gitDirMatch) return undefined;
|
|
278
|
+
const commonDir = commonGitDirectory(path.resolve(directory, gitDirMatch[1]));
|
|
279
|
+
return commonDir ? cleanProjectName(path.basename(path.dirname(commonDir))) || undefined : undefined;
|
|
280
|
+
} catch {
|
|
281
|
+
// Project names are best-effort when historical checkout metadata is missing.
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function projectNameFromCwd(cwd) {
|
|
287
|
+
if (!cwd) return "unknown project";
|
|
288
|
+
const resolved = path.resolve(cwd);
|
|
289
|
+
if (PROJECT_NAME_CACHE.has(resolved)) return PROJECT_NAME_CACHE.get(resolved);
|
|
290
|
+
|
|
291
|
+
let directory = resolved;
|
|
292
|
+
while (true) {
|
|
293
|
+
const project = projectNameFromGitMarker(directory);
|
|
294
|
+
if (project) {
|
|
295
|
+
PROJECT_NAME_CACHE.set(resolved, project);
|
|
296
|
+
return project;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const parent = path.dirname(directory);
|
|
300
|
+
if (parent === directory) break;
|
|
301
|
+
directory = parent;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Keep historical sessions grouped when a worktree's checkout has been removed.
|
|
305
|
+
const segments = resolved.split(path.sep);
|
|
306
|
+
const worktreesIndex = segments.lastIndexOf("worktrees");
|
|
307
|
+
const dotWorktreesIndex = segments.lastIndexOf(".worktrees");
|
|
308
|
+
let fallback = path.basename(resolved);
|
|
309
|
+
if (worktreesIndex >= 0) {
|
|
310
|
+
fallback = segments.length > worktreesIndex + 2
|
|
311
|
+
? segments[worktreesIndex + 1]
|
|
312
|
+
: segments[worktreesIndex - 1] || fallback;
|
|
313
|
+
} else if (dotWorktreesIndex > 0) {
|
|
314
|
+
fallback = segments[dotWorktreesIndex - 1];
|
|
315
|
+
}
|
|
316
|
+
const name = cleanProjectName(fallback) || "unknown project";
|
|
317
|
+
PROJECT_NAME_CACHE.set(resolved, name);
|
|
318
|
+
return name;
|
|
319
|
+
}
|
|
320
|
+
|
|
251
321
|
export function eventsForSession(session, timeZone, extraction = { formatter: localTimeFormatter(timeZone), toolCalls: new Map() }) {
|
|
252
322
|
const events = [];
|
|
253
323
|
const { formatter, toolCalls } = extraction;
|
|
254
324
|
const cwd = session.header.cwd || "";
|
|
255
|
-
const project = (cwd
|
|
325
|
+
const project = projectNameFromCwd(cwd);
|
|
256
326
|
|
|
257
327
|
for (const entry of session.entries) {
|
|
258
328
|
const generated = eventFor(entry, formatter, toolCalls);
|
package/src/work-blocks.js
CHANGED
|
@@ -79,6 +79,8 @@ export function workBlocksForSession(session, timeZone) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export function blockInRange(block, range) {
|
|
82
|
+
// Never create a partial summary of a work period that continues into today.
|
|
83
|
+
if (range.skipToday && block.activityDates.some((date) => date > range.lastDate)) return false;
|
|
82
84
|
return !range.firstDate || block.activityDates.some((date) => date >= range.firstDate && date <= range.lastDate);
|
|
83
85
|
}
|
|
84
86
|
|