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 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, or thinking level 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.
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 show effective settings and environment overrides
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, then run `init` to choose the daily-notes folder rather than the vault root.
199
- - **Pi cannot start:** run `doctor`, then use setup's advanced settings to set the executable path.
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:** rerun `init` and choose “no”, or set `PI_JOURNAL_AUTO=0`.
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
- const modelClient = createPiModelClient(generationSettings);
192
- const result = await writeSessions(modelClient, found.sessions, settings, {
193
- ...found,
194
- dryRun,
195
- onProgress: ({ index, total, sessionId, project, date, time, status, error, phase, sessionPath, dailyPath, prerequisite }) => {
196
- if (["checking", "skipped"].includes(phase)) return;
197
- console.log(`[${index}/${total}] ${project || sessionId}${date ? ` · ${date} ${time}` : ""}: ${error || status}`);
198
- if (dryRun && dailyPath) {
199
- console.log(` Daily note: ${dailyPath}`);
200
- console.log(` Full summary: ${sessionPath}`);
201
- if (prerequisite) console.log(" Includes this earlier block to establish the continuation link.");
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
- ? `\nWould create ${summaryCount}, reuse ${result.summariesReused}, append ${entryCount}; ${result.entriesSkipped} already present; update ${updateCount}.`
210
- : `\nSaved: ${summaryCount} created, ${result.summariesReused} reused, appended ${entryCount}; ${result.entriesSkipped} already present; updated ${updateCount}.`);
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 show settings and active environment overrides",
360
- " logdig backfill [N|all] [--dry-run] [--model provider/model|default]",
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] show journal coverage (default: last 3 days; read-only)",
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 === "LOGDIG_SETUP_CANCELLED" ? 130 : 1;
462
+ process.exitCode = ["LOGDIG_SETUP_CANCELLED", "LOGDIG_CONFIG_CANCELLED"].includes(error.code) ? 130 : 1;
432
463
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logdig",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Turn saved Pi sessions into a work journal in your Obsidian daily notes.",
5
5
  "license": "MIT",
6
6
  "author": "Soleone",
@@ -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, status, error, phase }) => {
72
- const message = `LogDig [${index}/${total}]${project ? ` ${project}` : ""}: ${error || status}`;
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 if (["summarizing", "complete", "error"].includes(phase)) console.log(message);
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 = before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
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
- const separator = !markdown ? "" : markdown.endsWith("\n\n") ? "" : markdown.endsWith("\n") ? "\n" : "\n\n";
572
- return `${markdown}${separator}${heading}\n\n${projectHeading}\n\n${text}\n`;
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
  }
@@ -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 today = localDate(Date.now(), timeZone);
74
- const firstDate = days ? shiftDate(today, 1 - days) : undefined;
75
- const modifiedAfter = days ? Date.now() - (days + 1) * 86400000 : undefined;
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: today };
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 errors = [];
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
- for (const [index, session] of sessions.entries()) {
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
- continue;
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?.({ ...progress, phase: "skipped", status: reason });
157
- continue;
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 = { ...progress, blockId: block.blockId, project: block.project, date: block.date, time: block.time };
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 = !blockInRange(block, range);
171
- range.onProgress?.({ ...blockProgress, phase: "checking", status: "checking saved summary" });
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: "summarizing with Pi; large sessions may take a few minutes" }),
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 appendDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry, journaledEntries);
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: (range.dryRun
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
- return { summariesCreated, summariesReused, entriesAppended, entriesUpdated, entriesSkipped, sessionsSkipped, dates: [...dates].sort(), dailyPaths: [...dailyPaths].sort(), errors, sessionResults };
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
- autoCapture: env.PI_JOURNAL_AUTO === "1",
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
- for (const [variable, key] of ENVIRONMENT_SETTINGS) {
137
- if (env[variable] !== undefined && env[variable] !== "") settings[key] = env[variable];
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
- const advanced = await ask("Change history folder, Pi executable, model, or thinking level? (yes/no)", "no", yesNo);
81
- let { sessionDirectory, piCommand, model, thinkingLevel } = settings;
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 ? path.basename(cwd) : "").replace(/^\d{4}-\d{2}-\d{2}-(?=.)/, "") || "unknown project";
325
+ const project = projectNameFromCwd(cwd);
256
326
 
257
327
  for (const entry of session.entries) {
258
328
  const generated = eventFor(entry, formatter, toolCalls);
@@ -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