logdig 0.1.0

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.
@@ -0,0 +1,127 @@
1
+ import os from "node:os";
2
+ import { JOURNAL_SYSTEM_PROMPT } from "./journal.js";
3
+ import { spawnPiProcess } from "./pi-process.js";
4
+
5
+ const MODEL_TIMEOUT_MS = 5 * 60 * 1000;
6
+ const MAX_STDOUT = 2_000_000;
7
+ const MAX_STDERR = 16_000;
8
+
9
+ function appendTail(current, chunk, limit) {
10
+ const text = current + chunk.toString("utf8");
11
+ return text.length > limit ? text.slice(-limit) : text;
12
+ }
13
+
14
+ function piArguments(settings) {
15
+ const args = [
16
+ "--mode", "json",
17
+ "--no-session",
18
+ "--no-tools",
19
+ "--no-extensions",
20
+ "--no-skills",
21
+ "--no-prompt-templates",
22
+ "--no-context-files",
23
+ "--system-prompt",
24
+ JOURNAL_SYSTEM_PROMPT,
25
+ ];
26
+ if (settings.model) args.push("--model", settings.model);
27
+ args.push("--", "Use the journal request and evidence supplied on standard input. Return the requested JSON only.");
28
+ return args;
29
+ }
30
+
31
+ function runPiPrompt(prompt, settings, spawnProcess) {
32
+ return new Promise((resolve, reject) => {
33
+ const child = spawnPiProcess(settings.piCommand || "pi", piArguments(settings), {
34
+ cwd: os.tmpdir(),
35
+ env: process.env,
36
+ stdio: ["pipe", "pipe", "pipe"],
37
+ }, { spawnProcess });
38
+ const decoder = new TextDecoder();
39
+ let buffer = "";
40
+ let outputLength = 0;
41
+ let stderr = "";
42
+ let settled = false;
43
+ let agentSettled = false;
44
+ let finalMessage;
45
+ const usages = [];
46
+
47
+ const finish = (error, result) => {
48
+ if (settled) return;
49
+ settled = true;
50
+ clearTimeout(timeout);
51
+ if (error) reject(error);
52
+ else resolve(result);
53
+ };
54
+
55
+ const timeout = setTimeout(() => {
56
+ child.kill("SIGTERM");
57
+ finish(new Error(`Pi summarization timed out after ${MODEL_TIMEOUT_MS / 1000} seconds`));
58
+ }, MODEL_TIMEOUT_MS);
59
+
60
+ child.stdout.on("data", (chunk) => {
61
+ outputLength += chunk.length;
62
+ if (outputLength > MAX_STDOUT) {
63
+ child.kill("SIGTERM");
64
+ finish(new Error("Pi summarization output exceeded the size limit"));
65
+ return;
66
+ }
67
+ buffer += decoder.decode(chunk, { stream: true });
68
+ let newline;
69
+ while ((newline = buffer.indexOf("\n")) !== -1) {
70
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
71
+ buffer = buffer.slice(newline + 1);
72
+ if (!line) continue;
73
+ let event;
74
+ try {
75
+ event = JSON.parse(line);
76
+ } catch {
77
+ child.kill("SIGTERM");
78
+ finish(new Error("Pi summarization returned invalid JSON events"));
79
+ return;
80
+ }
81
+ if (event.type === "message_end" && event.message?.role === "assistant") {
82
+ finalMessage = event.message;
83
+ if (event.message.usage) usages.push(event.message.usage);
84
+ }
85
+ if (event.type === "compaction_end" && event.result?.usage) usages.push(event.result.usage);
86
+ if (event.type === "agent_settled") agentSettled = true;
87
+ }
88
+ });
89
+ child.stderr.on("data", (chunk) => {
90
+ stderr = appendTail(stderr, chunk, MAX_STDERR);
91
+ });
92
+ child.once("error", (error) => {
93
+ finish(new Error(`Could not start Pi at '${settings.piCommand || "pi"}': ${error.message}`));
94
+ });
95
+ child.once("close", (code, signal) => {
96
+ if (settled) return;
97
+ buffer += decoder.decode();
98
+ if (code !== 0) {
99
+ const detail = stderr.trim();
100
+ finish(new Error(`Pi summarization failed${signal ? ` (${signal})` : ` with exit code ${code}`}${detail ? `: ${detail}` : ""}`));
101
+ return;
102
+ }
103
+ if (!agentSettled || !finalMessage || buffer.trim()) {
104
+ finish(new Error("Pi summarization returned incomplete JSON events"));
105
+ return;
106
+ }
107
+ if (["error", "aborted"].includes(finalMessage.stopReason)) {
108
+ finish(new Error(finalMessage.errorMessage || `Pi summarization ${finalMessage.stopReason}`));
109
+ return;
110
+ }
111
+ const text = (finalMessage.content || []).filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
112
+ finish(undefined, { text, usages });
113
+ });
114
+ child.stdin.once("error", (error) => {
115
+ if (error.code !== "EPIPE") finish(error);
116
+ });
117
+ child.stdin.end(prompt);
118
+ });
119
+ }
120
+
121
+ export function createPiModelClient(settings, options = {}) {
122
+ return {
123
+ modelLabel: settings.model || "Pi startup default",
124
+ cacheKey: settings.model || "Pi default",
125
+ complete: (prompt) => runPiPrompt(prompt, settings, options.spawnProcess),
126
+ };
127
+ }
@@ -0,0 +1,19 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ function quoteCmdArgument(value) {
4
+ const text = String(value);
5
+ if (/[\0\r\n"]/.test(text) || text.includes("%")) {
6
+ throw new Error("Pi command arguments cannot contain quotes, newlines, or percent signs on Windows");
7
+ }
8
+ return `"${text}"`;
9
+ }
10
+
11
+ export function spawnPiProcess(command, args, options, { platform = process.platform, spawnProcess = spawn } = {}) {
12
+ if (platform !== "win32") return spawnProcess(command, args, options);
13
+
14
+ const commandLine = [command, ...args].map(quoteCmdArgument).join(" ");
15
+ return spawnProcess(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/v:off", "/c", `"${commandLine}"`], {
16
+ ...options,
17
+ windowsVerbatimArguments: true,
18
+ });
19
+ }
@@ -0,0 +1,185 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { appendDailyEntry, inspectDailyEntry, inspectSessionSummary, saveSessionSummary } from "./journal.js";
4
+ import { eventsForSession, fingerprintSession, sessionFromJsonl, sessionMetrics } from "./transcript.js";
5
+
6
+ function localDate(timestamp, timeZone) {
7
+ const parts = new Intl.DateTimeFormat("en-CA", {
8
+ timeZone,
9
+ year: "numeric",
10
+ month: "2-digit",
11
+ day: "2-digit",
12
+ }).formatToParts(new Date(timestamp));
13
+ const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
14
+ return `${values.year}-${values.month}-${values.day}`;
15
+ }
16
+
17
+ function shiftDate(date, amount) {
18
+ const [year, month, day] = date.split("-").map(Number);
19
+ const shifted = new Date(Date.UTC(year, month - 1, day + amount));
20
+ return shifted.toISOString().slice(0, 10);
21
+ }
22
+
23
+ function sessionEndTime(session) {
24
+ for (let index = session.entries.length - 1; index >= 0; index--) {
25
+ const entry = session.entries[index];
26
+ const value = entry.timestamp ?? entry.message?.timestamp;
27
+ if (typeof value === "number") return value;
28
+ if (typeof value === "string") {
29
+ const parsed = Date.parse(value);
30
+ if (Number.isFinite(parsed)) return parsed;
31
+ }
32
+ }
33
+ const headerTime = Date.parse(session.header.timestamp || "");
34
+ return Number.isFinite(headerTime) ? headerTime : 0;
35
+ }
36
+
37
+ async function findSessionFiles(root) {
38
+ const files = [];
39
+ const warnings = [];
40
+ const pending = [root];
41
+
42
+ while (pending.length > 0) {
43
+ const directory = pending.pop();
44
+ let entries;
45
+ try {
46
+ entries = await readdir(directory, { withFileTypes: true });
47
+ } catch (error) {
48
+ if (error.code === "ENOENT") {
49
+ if (directory === root) warnings.push(`Pi history folder not found: ${root}. Start a saved Pi session or check the history path with 'logdig init'.`);
50
+ continue;
51
+ }
52
+ throw error;
53
+ }
54
+
55
+ for (const entry of entries) {
56
+ const fullPath = path.join(directory, entry.name);
57
+ if (entry.isDirectory()) {
58
+ if (entry.name !== "subagent-artifacts") pending.push(fullPath);
59
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl") && entry.name !== "session.jsonl") {
60
+ files.push(fullPath);
61
+ }
62
+ }
63
+ }
64
+
65
+ return { files, warnings };
66
+ }
67
+
68
+ export async function collectSessions({ sessionDirectory, timeZone, days, currentSession }) {
69
+ const { files, warnings } = await findSessionFiles(sessionDirectory);
70
+ const currentId = currentSession?.header.id;
71
+ const currentPath = currentSession?.sourcePath;
72
+ const today = localDate(Date.now(), timeZone);
73
+ const firstDate = days ? shiftDate(today, 1 - days) : undefined;
74
+ const modifiedAfter = days ? Date.now() - (days + 1) * 86400000 : undefined;
75
+ const sessions = [];
76
+ const seen = new Set();
77
+
78
+ for (const file of files) {
79
+ if (file === currentPath) continue;
80
+
81
+ try {
82
+ const fileStat = await stat(file);
83
+ if (modifiedAfter && fileStat.mtimeMs < modifiedAfter) continue;
84
+ const session = sessionFromJsonl(await readFile(file, "utf8"), file);
85
+ if (session.header.id === currentId || seen.has(session.header.id)) continue;
86
+ session.fingerprint = fingerprintSession(session);
87
+ seen.add(session.header.id);
88
+ sessions.push(session);
89
+ } catch (error) {
90
+ warnings.push(`${path.basename(file)}: ${error.message}`);
91
+ }
92
+ }
93
+
94
+ if (currentSession && !seen.has(currentSession.header.id)) sessions.push(currentSession);
95
+ sessions.sort((left, right) => sessionEndTime(left) - sessionEndTime(right) || left.header.id.localeCompare(right.header.id));
96
+ return { sessions, warnings, firstDate, lastDate: today };
97
+ }
98
+
99
+ export async function writeSessions(modelClient, sessions, settings, range = {}) {
100
+ let summariesCreated = 0;
101
+ let summariesReused = 0;
102
+ let entriesAppended = 0;
103
+ let entriesSkipped = 0;
104
+ let sessionsSkipped = 0;
105
+ const dates = new Set();
106
+ const dailyPaths = new Set();
107
+ const errors = [];
108
+
109
+ for (const [index, session] of sessions.entries()) {
110
+ const progress = { index: index + 1, total: sessions.length, sessionId: session.header.id };
111
+ try {
112
+ const events = eventsForSession(session, settings.timeZone);
113
+ const lastUserEvent = events.findLast((event) => event.kind === "user intent");
114
+ const closingEvent = lastUserEvent || events.at(-1);
115
+ if (!closingEvent || (range.firstDate && (closingEvent.date < range.firstDate || closingEvent.date > range.lastDate))) {
116
+ sessionsSkipped++;
117
+ range.onProgress?.({ ...progress, phase: "skipped", status: closingEvent ? "outside date range" : "no journalable events" });
118
+ continue;
119
+ }
120
+
121
+ const journalSession = {
122
+ ...session,
123
+ date: closingEvent.date,
124
+ time: closingEvent.time,
125
+ timezone: settings.timeZone,
126
+ project: events[0].project || "unknown project",
127
+ events,
128
+ sourceFingerprint: session.fingerprint || fingerprintSession(session),
129
+ };
130
+ Object.assign(progress, { project: journalSession.project, date: journalSession.date, time: journalSession.time });
131
+ range.onProgress?.({ ...progress, phase: "checking", status: "checking saved summary" });
132
+ const cached = range.dryRun
133
+ ? await inspectSessionSummary(modelClient, settings.cacheDirectory, journalSession)
134
+ : await saveSessionSummary(modelClient, settings.cacheDirectory, journalSession, {
135
+ onGenerate: () => range.onProgress?.({ ...progress, phase: "summarizing", status: "summarizing with Pi; large sessions may take a few minutes" }),
136
+ });
137
+ if (cached.reused) summariesReused++;
138
+ else summariesCreated++;
139
+
140
+ const entry = {
141
+ date: journalSession.date,
142
+ time: journalSession.time,
143
+ project: journalSession.project,
144
+ sessionId: journalSession.header.id,
145
+ cacheFingerprint: cached.cacheFingerprint,
146
+ sessionPath: cached.sessionPath,
147
+ sourceFingerprint: journalSession.sourceFingerprint,
148
+ metrics: sessionMetrics(journalSession),
149
+ summaryLevel: settings.dailySummary,
150
+ summary: cached.summary?.[settings.dailySummary],
151
+ };
152
+ const dailyEntry = range.dryRun
153
+ ? await inspectDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry)
154
+ : await appendDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry);
155
+ if (dailyEntry.appended) entriesAppended++;
156
+ else entriesSkipped++;
157
+ dates.add(journalSession.date);
158
+ dailyPaths.add(dailyEntry.dailyPath);
159
+ range.onProgress?.({
160
+ ...progress,
161
+ phase: "complete",
162
+ sessionPath: cached.sessionPath,
163
+ dailyPath: dailyEntry.dailyPath,
164
+ status: range.dryRun
165
+ ? `${cached.reused ? "would reuse summary" : "would summarize with Pi"}, ${dailyEntry.appended ? "would append entry" : "entry already present"}`
166
+ : `${cached.reused ? "summary reused" : "summary created"}, ${dailyEntry.appended ? "entry appended" : "entry already present"}`,
167
+ });
168
+ } catch (error) {
169
+ errors.push(`${session.header.id}: ${error.message}`);
170
+ range.onProgress?.({ ...progress, phase: "error", error: error.message });
171
+ }
172
+ }
173
+
174
+ return { summariesCreated, summariesReused, entriesAppended, entriesSkipped, sessionsSkipped, dates: [...dates].sort(), dailyPaths: [...dailyPaths].sort(), errors };
175
+ }
176
+
177
+ export function parseBackfillArgument(argument = "") {
178
+ const value = argument.trim().toLowerCase() || "3";
179
+ if (value === "all") return { all: true };
180
+ const days = Number(value);
181
+ if (!/^\d+$/.test(value) || !Number.isInteger(days) || days < 1 || days > 3650) {
182
+ throw new Error("Usage: logdig backfill [number-of-days|all]. Choose 1 to 3650 days, or 'all'.");
183
+ }
184
+ return { days };
185
+ }
@@ -0,0 +1,155 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ const ENVIRONMENT_SETTINGS = [
7
+ ["PI_JOURNAL_DIR", "cacheDirectory"],
8
+ ["PI_JOURNAL_DAILY_DIR", "dailyDirectory"],
9
+ ["PI_JOURNAL_DAILY_HEADER", "dailyHeader"],
10
+ ["PI_JOURNAL_DAILY_SUMMARY", "dailySummary"],
11
+ ["PI_JOURNAL_TIMEZONE", "timeZone"],
12
+ ["PI_JOURNAL_MODEL", "model"],
13
+ ["PI_JOURNAL_PI_COMMAND", "piCommand"],
14
+ ["PI_CODING_AGENT_SESSION_DIR", "sessionDirectory"],
15
+ ];
16
+
17
+ function expandPath(value, home) {
18
+ if (value === "~") return home;
19
+ if (value.startsWith("~/")) return path.join(home, value.slice(2));
20
+ return path.resolve(value);
21
+ }
22
+
23
+ export function settingsFilePath({ platform = process.platform, env = process.env, home = homedir() } = {}) {
24
+ if (env.LOGDIG_CONFIG_PATH) return expandPath(env.LOGDIG_CONFIG_PATH, home);
25
+
26
+ if (platform === "win32") {
27
+ return path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "LogDig", "settings.json");
28
+ }
29
+ if (platform === "darwin") {
30
+ return path.join(home, "Library", "Application Support", "LogDig", "settings.json");
31
+ }
32
+ return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "logdig", "settings.json");
33
+ }
34
+
35
+ function defaults({ env = process.env, home = homedir() } = {}) {
36
+ const agentDirectory = env.PI_CODING_AGENT_DIR || env.PI || path.join(home, ".pi", "agent");
37
+ return {
38
+ version: 1,
39
+ cacheDirectory: undefined,
40
+ dailyDirectory: undefined,
41
+ dailyHeader: "# Projects",
42
+ dailySummary: "small",
43
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
44
+ sessionDirectory: path.join(expandPath(agentDirectory, home), "sessions"),
45
+ piCommand: "pi",
46
+ autoCapture: env.PI_JOURNAL_AUTO === "1",
47
+ };
48
+ }
49
+
50
+ export function validateSettings(settings, { requirePaths = false, home = homedir() } = {}) {
51
+ if (settings.version !== undefined && settings.version !== 1) throw new Error(`Unsupported settings version: ${settings.version}`);
52
+ if (requirePaths && !settings.cacheDirectory) throw new Error("Set cacheDirectory to a LogDig save folder");
53
+ if (requirePaths && !settings.dailyDirectory) throw new Error("Set dailyDirectory to the folder containing YYYY-MM-DD.md notes");
54
+ if (settings.dailySummary !== undefined) {
55
+ if (typeof settings.dailySummary !== "string" || !settings.dailySummary.trim()) throw new Error("dailySummary must be small, medium, or large");
56
+ settings.dailySummary = settings.dailySummary.trim().toLowerCase();
57
+ if (!["small", "medium", "large"].includes(settings.dailySummary)) throw new Error("dailySummary must be small, medium, or large");
58
+ }
59
+ if (typeof settings.dailyHeader !== "string" || !settings.dailyHeader.trim()) {
60
+ throw new Error("dailyHeader must be one Markdown heading, such as '# Projects'");
61
+ }
62
+ settings.dailyHeader = settings.dailyHeader.trim();
63
+ if (!/^#{1,6}\s+[^\r\n]+$/.test(settings.dailyHeader)) {
64
+ throw new Error("dailyHeader must be one Markdown heading, such as '# Projects'");
65
+ }
66
+ if (typeof settings.timeZone !== "string" || !settings.timeZone.trim()) throw new Error("timeZone must be a valid timezone");
67
+ try {
68
+ new Intl.DateTimeFormat("en", { timeZone: settings.timeZone });
69
+ } catch {
70
+ throw new Error(`Invalid timeZone: ${settings.timeZone}`);
71
+ }
72
+ for (const key of ["cacheDirectory", "dailyDirectory", "sessionDirectory"]) {
73
+ if (settings[key] !== undefined && (typeof settings[key] !== "string" || !settings[key].trim())) {
74
+ throw new Error(`${key} must be a non-empty path`);
75
+ }
76
+ if (settings[key]) settings[key] = expandPath(settings[key].trim(), home);
77
+ }
78
+ if (settings.model !== undefined) {
79
+ if (typeof settings.model !== "string" || /[\r\n]/.test(settings.model)) throw new Error("model must use provider/model format");
80
+ settings.model = settings.model.trim();
81
+ const separator = settings.model.indexOf("/");
82
+ if (separator < 1 || separator === settings.model.length - 1) throw new Error("model must use provider/model format");
83
+ const provider = settings.model.slice(0, separator).trim();
84
+ const modelId = settings.model.slice(separator + 1).trim();
85
+ if (!provider || !modelId) throw new Error("model must use provider/model format");
86
+ settings.model = `${provider}/${modelId}`;
87
+ }
88
+ if (settings.autoCapture !== undefined && typeof settings.autoCapture !== "boolean") {
89
+ throw new Error("autoCapture must be a boolean");
90
+ }
91
+ if (typeof settings.piCommand !== "string" || !settings.piCommand.trim() || /[\r\n]/.test(settings.piCommand)) {
92
+ throw new Error("piCommand must be a command or executable path");
93
+ }
94
+ settings.piCommand = settings.piCommand.trim();
95
+ if (settings.piCommand === "~" || settings.piCommand.startsWith("~/")) {
96
+ settings.piCommand = expandPath(settings.piCommand, home);
97
+ } else if (settings.piCommand.includes("/") || settings.piCommand.includes("\\")) {
98
+ settings.piCommand = path.resolve(settings.piCommand);
99
+ }
100
+ return settings;
101
+ }
102
+
103
+ export function environmentOverrides(env = process.env) {
104
+ return [...ENVIRONMENT_SETTINGS.map(([variable]) => variable), "PI_JOURNAL_AUTO"]
105
+ .filter((variable) => env[variable] !== undefined && (env[variable] !== "" || variable === "PI_JOURNAL_AUTO"));
106
+ }
107
+
108
+ export async function loadSettings(options = {}) {
109
+ const env = options.env || process.env;
110
+ const home = options.home || homedir();
111
+ const filePath = options.filePath || settingsFilePath({ platform: options.platform, env, home });
112
+ let stored = {};
113
+ let configured = false;
114
+
115
+ try {
116
+ stored = JSON.parse(await readFile(filePath, "utf8"));
117
+ configured = true;
118
+ } catch (error) {
119
+ if (error.code !== "ENOENT") throw new Error(`Could not read LogDig settings at ${filePath}: ${error.message}`);
120
+ }
121
+
122
+ if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
123
+ throw new Error(`LogDig settings at ${filePath} must contain a JSON object`);
124
+ }
125
+
126
+ const settings = { ...defaults({ env, home }), ...stored };
127
+ for (const [variable, key] of ENVIRONMENT_SETTINGS) {
128
+ if (env[variable] !== undefined && env[variable] !== "") settings[key] = env[variable];
129
+ }
130
+ if (env.PI_JOURNAL_AUTO !== undefined) settings.autoCapture = env.PI_JOURNAL_AUTO === "1" || env.PI_JOURNAL_AUTO === "true";
131
+ validateSettings(settings);
132
+ return { ...settings, filePath, configured };
133
+ }
134
+
135
+ export async function saveSettings(settings, filePath = settings.filePath || settingsFilePath()) {
136
+ const persisted = {
137
+ version: 1,
138
+ cacheDirectory: settings.cacheDirectory,
139
+ dailyDirectory: settings.dailyDirectory,
140
+ dailyHeader: settings.dailyHeader || "# Projects",
141
+ dailySummary: settings.dailySummary || "small",
142
+ timeZone: settings.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
143
+ sessionDirectory: settings.sessionDirectory,
144
+ piCommand: settings.piCommand || "pi",
145
+ autoCapture: settings.autoCapture === true,
146
+ ...(settings.model ? { model: settings.model } : {}),
147
+ };
148
+ validateSettings(persisted, { requirePaths: true });
149
+
150
+ await mkdir(path.dirname(filePath), { recursive: true });
151
+ const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
152
+ await writeFile(tempPath, `${JSON.stringify(persisted, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
153
+ await rename(tempPath, filePath);
154
+ return filePath;
155
+ }
package/src/setup.js ADDED
@@ -0,0 +1,122 @@
1
+ import { homedir } from "node:os";
2
+ import path from "node:path";
3
+ import { createInterface } from "node:readline";
4
+ import { stdin, stdout } from "node:process";
5
+ import { checkDirectory } from "./directories.js";
6
+ import { environmentOverrides, saveSettings, validateSettings } from "./settings.js";
7
+
8
+ function yesNo(value) {
9
+ if (/^(y|yes)$/i.test(value)) return true;
10
+ if (/^(n|no)$/i.test(value)) return false;
11
+ throw new Error("Please enter yes or no");
12
+ }
13
+
14
+ export async function configureLogDig(settings, commandName = "logdig") {
15
+ console.log([
16
+ "LogDig setup",
17
+ "A small work journal from your Pi sessions, in the notes you already use.",
18
+ "",
19
+ "Press Enter to keep a default. Ctrl+C leaves your settings unchanged.",
20
+ "No history is summarized and no daily notes are written during setup.",
21
+ `Settings: ${settings.filePath}`,
22
+ "",
23
+ ].join("\n"));
24
+ const overrides = environmentOverrides();
25
+ if (overrides.length) {
26
+ console.warn(`Environment overrides are active: ${overrides.join(", ")}. They will still take precedence over saved settings.`);
27
+ }
28
+
29
+ const rl = createInterface({ input: stdin, output: stdout });
30
+ const answers = rl[Symbol.asyncIterator]();
31
+ let interrupted = false;
32
+ const cancel = () => { interrupted = true; rl.close(); };
33
+ rl.on("SIGINT", cancel);
34
+ process.once("SIGINT", cancel);
35
+ let nextSettings;
36
+ let installExtension;
37
+
38
+ try {
39
+ const ask = async (label, defaultValue, parse = (value) => value) => {
40
+ while (true) {
41
+ stdout.write(`${label}${defaultValue ? ` [${defaultValue}]` : ""}: `);
42
+ const answer = await answers.next();
43
+ if (interrupted) {
44
+ const error = new Error("Setup cancelled. Your settings were not changed.");
45
+ error.code = "LOGDIG_SETUP_CANCELLED";
46
+ throw error;
47
+ }
48
+ if (answer.done) throw new Error(`Setup ended early. Your settings were not changed. Run '${commandName} init' to try again.`);
49
+ try {
50
+ return await parse(answer.value.trim() || defaultValue || "");
51
+ } catch (error) {
52
+ console.log(` ${error.message}. Let's try that again.`);
53
+ }
54
+ }
55
+ };
56
+ const field = (key, value) => validateSettings({ ...settings, [key]: value })[key];
57
+ const folder = async (key, value, writable) => {
58
+ const directory = field(key, value);
59
+ await checkDirectory(directory, { writable, allowMissing: true });
60
+ return directory;
61
+ };
62
+
63
+ console.log("1. Your notes");
64
+ console.log("Choose the folder holding YYYY-MM-DD.md daily notes, not the vault root unless your notes live there.");
65
+ const dailyDirectory = await ask("Daily-notes folder", settings.dailyDirectory || path.join(homedir(), "Obsidian", "Daily"),
66
+ (value) => folder("dailyDirectory", value, true));
67
+ console.log("The cache keeps all three summary lengths. Put it inside your vault to browse it in Obsidian.");
68
+ const cacheDirectory = await ask("Summary cache folder", settings.cacheDirectory || path.join(path.dirname(dailyDirectory), "LogDig"),
69
+ (value) => folder("cacheDirectory", value, true));
70
+ console.log("Entries are grouped by project, then by time, under this heading. Use # Projects to keep your personal log separate.");
71
+ const dailyHeader = await ask("Heading for journal entries", settings.dailyHeader, (value) => field("dailyHeader", value));
72
+ console.log("Small: a few sentences. Medium: goal, progress, and next steps. Large: a short timeline.");
73
+ const dailySummary = await ask("Daily summary (small/medium/large)", settings.dailySummary, (value) => field("dailySummary", value));
74
+ const timeZone = await ask("Timezone", settings.timeZone, (value) => field("timeZone", value));
75
+
76
+ console.log("\n2. Pi and privacy");
77
+ console.log(`History: ${settings.sessionDirectory}`);
78
+ console.log(`Model: ${settings.model || "Pi's default (your active model for /journal)"}`);
79
+ const advanced = await ask("Change history folder, Pi executable, or model? (yes/no)", "no", yesNo);
80
+ let { sessionDirectory, piCommand, model } = settings;
81
+ if (advanced) {
82
+ sessionDirectory = await ask("Pi session-history folder", sessionDirectory,
83
+ (value) => folder("sessionDirectory", value, false));
84
+ piCommand = await ask("Pi executable", piCommand, (value) => field("piCommand", value));
85
+ model = await ask("Model (provider/model), or 'default'", model || "default",
86
+ (value) => value.toLowerCase() === "default" ? undefined : field("model", value));
87
+ }
88
+ console.log("Summarizing sends selected, redacted history to your Pi model. Provider charges may apply.");
89
+ console.log("Common secrets are redacted, but this is not a complete secret scanner. Pi keeps your credentials.");
90
+ console.log("Automatic capture may delay shutdown. Leave it off until you've tried a manual run.");
91
+ const autoCapture = await ask("Capture on Pi shutdown? (yes/no)", settings.autoCapture ? "yes" : "no", yesNo);
92
+ installExtension = await ask("Install the Pi extension for /journal? (yes/no)", "no", yesNo);
93
+
94
+ nextSettings = { ...settings, cacheDirectory, dailyDirectory, dailyHeader, dailySummary, timeZone, sessionDirectory, piCommand, model, autoCapture };
95
+ console.log([
96
+ "",
97
+ "Ready to save",
98
+ ` Daily notes: ${dailyDirectory}/YYYY-MM-DD.md`,
99
+ ` Heading: ${dailyHeader}`,
100
+ ` Summary: ${dailySummary}, in ${timeZone}`,
101
+ ` Cache: ${path.join(cacheDirectory, "Sessions")}`,
102
+ ` History: ${sessionDirectory}`,
103
+ ` Pi: ${piCommand}`,
104
+ ` Model: ${model || "Pi default"}`,
105
+ ` Auto-capture: ${autoCapture ? "on" : "off"}`,
106
+ ` Extension: ${installExtension ? "install now" : "leave unchanged"}`,
107
+ "Missing note and cache folders will be created on your first real backfill.",
108
+ ].join("\n"));
109
+ if (!await ask("Save these settings? (yes/no)", "yes", yesNo)) {
110
+ console.log("Setup cancelled. Your settings were not changed.");
111
+ return undefined;
112
+ }
113
+ } finally {
114
+ process.removeListener("SIGINT", cancel);
115
+ rl.close();
116
+ }
117
+
118
+ const configPath = await saveSettings(nextSettings, settings.filePath);
119
+ console.log(`\nSaved settings: ${configPath}`);
120
+ console.log(`Next: ${commandName} doctor, then ${commandName} backfill 1 --dry-run. Preview is free and changes nothing.`);
121
+ return { settings: nextSettings, installExtension };
122
+ }