harulog 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,471 @@
1
+ import { execFile } from "node:child_process";
2
+ import { lstat, readdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ import type { AppConfig } from "../config";
7
+ import type { ActivityItem } from "../types";
8
+ import { subtractDays } from "../utils/date";
9
+ import { logInfo, logWarn } from "../utils/logger";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+ const FIELD_SEPARATOR = "\u001f";
13
+ const RECORD_SEPARATOR = "\u001e";
14
+ const MAX_CHANGED_FILES = 8;
15
+ const DIRECTORY_SKIP_NAMES = new Set([
16
+ ".git",
17
+ "node_modules",
18
+ "dist",
19
+ "build",
20
+ "coverage",
21
+ ".next",
22
+ ".turbo"
23
+ ]);
24
+
25
+ interface CommitSnapshot {
26
+ sha: string;
27
+ timestamp: string;
28
+ subject: string;
29
+ changedFiles: string[];
30
+ }
31
+
32
+ interface PushSnapshot {
33
+ sha: string;
34
+ timestamp: string;
35
+ selector: string;
36
+ shortRef: string;
37
+ message: string;
38
+ }
39
+
40
+ function tokenize(value: string): string[] {
41
+ return value
42
+ .toLowerCase()
43
+ .split(/[^a-z0-9가-힣]+/u)
44
+ .map((item) => item.trim())
45
+ .filter((item) => item.length >= 2);
46
+ }
47
+
48
+ function uniqueStrings(values: string[], limit = values.length): string[] {
49
+ const result: string[] = [];
50
+ const seen = new Set<string>();
51
+
52
+ for (const value of values) {
53
+ const trimmed = value.trim();
54
+ const normalized = trimmed.toLowerCase();
55
+ if (!trimmed || seen.has(normalized)) {
56
+ continue;
57
+ }
58
+
59
+ seen.add(normalized);
60
+ result.push(trimmed);
61
+
62
+ if (result.length >= limit) {
63
+ break;
64
+ }
65
+ }
66
+
67
+ return result;
68
+ }
69
+
70
+ function sanitizeRepoTag(repoName: string): string {
71
+ return repoName.toLowerCase().replace(/[^a-z0-9]+/g, "-");
72
+ }
73
+
74
+ function sanitizeIdSegment(value: string): string {
75
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
76
+ }
77
+
78
+ function summarizeFiles(files: string[]): string {
79
+ if (files.length === 0) {
80
+ return "변경 파일 정보 없음";
81
+ }
82
+
83
+ const visibleFiles = files.slice(0, 3).join(", ");
84
+ if (files.length <= 3) {
85
+ return visibleFiles;
86
+ }
87
+
88
+ return `${visibleFiles} 외 ${files.length - 3}개`;
89
+ }
90
+
91
+ function createCommitSummary(repoName: string, subject: string, files: string[]): string {
92
+ if (files.length === 0) {
93
+ return `${repoName} 저장소에서 "${subject}" 커밋이 기록되었다.`;
94
+ }
95
+
96
+ return `${repoName} 저장소에서 "${subject}" 커밋이 기록되었고, ${files.length}개 파일이 함께 변경되었다. 핵심 파일: ${summarizeFiles(files)}.`;
97
+ }
98
+
99
+ function createPushSummary(
100
+ repoName: string,
101
+ push: PushSnapshot,
102
+ commit: CommitSnapshot | null
103
+ ): string {
104
+ const refName = push.shortRef;
105
+ if (!commit) {
106
+ return `${repoName} 저장소에서 ${refName} 기준 push 이력이 감지되었다. reflog 메시지: ${push.message}.`;
107
+ }
108
+
109
+ if (commit.changedFiles.length === 0) {
110
+ return `${repoName} 저장소에서 ${refName} 기준 push 이력이 감지되었다. 관련 커밋은 "${commit.subject}"였다.`;
111
+ }
112
+
113
+ return `${repoName} 저장소에서 ${refName} 기준 push 이력이 감지되었다. 관련 커밋은 "${commit.subject}"였고, 함께 바뀐 파일은 ${summarizeFiles(commit.changedFiles)}다.`;
114
+ }
115
+
116
+ function createTags(repoName: string, subject: string, files: string[], extra: string[] = []): string[] {
117
+ const fileTokens = files.flatMap((item) => tokenize(path.basename(item)));
118
+ return uniqueStrings(
119
+ [sanitizeRepoTag(repoName), ...tokenize(subject), ...fileTokens, ...extra.flatMap(tokenize)],
120
+ 8
121
+ );
122
+ }
123
+
124
+ async function pathExists(targetPath: string): Promise<boolean> {
125
+ try {
126
+ await lstat(targetPath);
127
+ return true;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ async function runGit(repoPath: string, args: string[]): Promise<string> {
134
+ const { stdout } = await execFileAsync("git", ["-c", "core.quotepath=false", ...args], {
135
+ cwd: repoPath,
136
+ maxBuffer: 1024 * 1024 * 8
137
+ });
138
+
139
+ return stdout;
140
+ }
141
+
142
+ async function isGitRepository(repoPath: string): Promise<boolean> {
143
+ return pathExists(path.join(repoPath, ".git"));
144
+ }
145
+
146
+ async function discoverGitRepos(rootPath: string, maxDepth: number): Promise<string[]> {
147
+ if (!(await pathExists(rootPath))) {
148
+ logWarn(`Local git scan root not found: ${rootPath}`);
149
+ return [];
150
+ }
151
+
152
+ const discovered: string[] = [];
153
+ const queue: Array<{ dirPath: string; depth: number }> = [{ dirPath: rootPath, depth: 0 }];
154
+ const visited = new Set<string>();
155
+
156
+ while (queue.length > 0) {
157
+ const current = queue.shift();
158
+ if (!current || visited.has(current.dirPath)) {
159
+ continue;
160
+ }
161
+
162
+ visited.add(current.dirPath);
163
+
164
+ if (await isGitRepository(current.dirPath)) {
165
+ discovered.push(current.dirPath);
166
+ continue;
167
+ }
168
+
169
+ if (current.depth >= maxDepth) {
170
+ continue;
171
+ }
172
+
173
+ let entries;
174
+ try {
175
+ entries = await readdir(current.dirPath, { withFileTypes: true });
176
+ } catch {
177
+ continue;
178
+ }
179
+
180
+ for (const entry of entries) {
181
+ if (!entry.isDirectory()) {
182
+ continue;
183
+ }
184
+
185
+ if (DIRECTORY_SKIP_NAMES.has(entry.name)) {
186
+ continue;
187
+ }
188
+
189
+ if (entry.name.startsWith(".") && entry.name !== ".config") {
190
+ continue;
191
+ }
192
+
193
+ queue.push({
194
+ dirPath: path.join(current.dirPath, entry.name),
195
+ depth: current.depth + 1
196
+ });
197
+ }
198
+ }
199
+
200
+ return discovered;
201
+ }
202
+
203
+ function parseCommitLog(output: string): CommitSnapshot[] {
204
+ return output
205
+ .split(RECORD_SEPARATOR)
206
+ .map((record) => record.trim())
207
+ .filter(Boolean)
208
+ .map((record) => {
209
+ const lines = record.split("\n").map((line) => line.trimEnd());
210
+ const [sha = "", timestamp = "", subject = ""] = (lines.shift() ?? "").split(FIELD_SEPARATOR);
211
+ const changedFiles = uniqueStrings(
212
+ lines.map((line) => line.trim()).filter(Boolean),
213
+ MAX_CHANGED_FILES
214
+ );
215
+
216
+ return {
217
+ sha: sha.trim(),
218
+ timestamp: timestamp.trim(),
219
+ subject: subject.trim(),
220
+ changedFiles
221
+ };
222
+ })
223
+ .filter((record) => record.sha && record.timestamp && record.subject);
224
+ }
225
+
226
+ function parseReflogTimestamp(selector: string): string | null {
227
+ const match = selector.match(/@\{(.+)\}$/);
228
+ return match?.[1]?.trim() || null;
229
+ }
230
+
231
+ function parsePushLog(output: string): PushSnapshot[] {
232
+ return output
233
+ .split("\n")
234
+ .map((line) => line.trim())
235
+ .filter(Boolean)
236
+ .map((line) => {
237
+ const [sha = "", selector = "", message = ""] = line.split(FIELD_SEPARATOR);
238
+ const timestamp = parseReflogTimestamp(selector) ?? "";
239
+ const shortRef = selector.includes("@{") ? selector.slice(0, selector.indexOf("@{")) : selector;
240
+
241
+ return {
242
+ sha: sha.trim(),
243
+ timestamp,
244
+ selector: selector.trim(),
245
+ shortRef: shortRef.trim(),
246
+ message: message.trim()
247
+ };
248
+ })
249
+ .filter((record) => record.sha && record.timestamp && record.shortRef)
250
+ .filter((record) => record.shortRef !== "origin/HEAD")
251
+ .filter((record) => record.message.toLowerCase().includes("push"));
252
+ }
253
+
254
+ async function loadCommitSnapshots(repoPath: string, sinceIso: string): Promise<CommitSnapshot[]> {
255
+ const output = await runGit(repoPath, [
256
+ "log",
257
+ `--since=${sinceIso}`,
258
+ "--date=iso-strict",
259
+ `--pretty=format:${RECORD_SEPARATOR}%H${FIELD_SEPARATOR}%cI${FIELD_SEPARATOR}%s`,
260
+ "--name-only"
261
+ ]);
262
+
263
+ return parseCommitLog(output);
264
+ }
265
+
266
+ async function loadCommitSnapshotForSha(
267
+ repoPath: string,
268
+ sha: string,
269
+ fallbackTimestamp: string
270
+ ): Promise<CommitSnapshot | null> {
271
+ try {
272
+ const output = await runGit(repoPath, [
273
+ "show",
274
+ "--date=iso-strict",
275
+ `--pretty=format:${RECORD_SEPARATOR}%H${FIELD_SEPARATOR}%cI${FIELD_SEPARATOR}%s`,
276
+ "--name-only",
277
+ sha
278
+ ]);
279
+
280
+ const snapshot = parseCommitLog(output)[0];
281
+ if (!snapshot) {
282
+ return null;
283
+ }
284
+
285
+ return {
286
+ ...snapshot,
287
+ timestamp: snapshot.timestamp || fallbackTimestamp
288
+ };
289
+ } catch {
290
+ return null;
291
+ }
292
+ }
293
+
294
+ async function loadRemoteRefs(repoPath: string): Promise<string[]> {
295
+ try {
296
+ const output = await runGit(repoPath, ["for-each-ref", "--format=%(refname:short)", "refs/remotes"]);
297
+ return output
298
+ .split("\n")
299
+ .map((line) => line.trim())
300
+ .filter(Boolean)
301
+ .filter((ref) => !ref.endsWith("/HEAD"));
302
+ } catch {
303
+ return [];
304
+ }
305
+ }
306
+
307
+ async function loadPushSnapshots(repoPath: string): Promise<PushSnapshot[]> {
308
+ const remoteRefs = await loadRemoteRefs(repoPath);
309
+ if (remoteRefs.length === 0) {
310
+ return [];
311
+ }
312
+
313
+ const outputs = await Promise.all(
314
+ remoteRefs.map(async (remoteRef) => {
315
+ try {
316
+ const output = await runGit(repoPath, [
317
+ "reflog",
318
+ "show",
319
+ remoteRef,
320
+ "--date=iso-strict",
321
+ `--format=%H${FIELD_SEPARATOR}%gd${FIELD_SEPARATOR}%gs`,
322
+ "-n",
323
+ "30"
324
+ ]);
325
+ return parsePushLog(output);
326
+ } catch {
327
+ return [];
328
+ }
329
+ })
330
+ );
331
+
332
+ const deduped = new Map<string, PushSnapshot>();
333
+ for (const push of outputs.flat()) {
334
+ const key = `${push.shortRef}:${push.timestamp}:${push.sha}`;
335
+ if (!deduped.has(key)) {
336
+ deduped.set(key, push);
337
+ }
338
+ }
339
+
340
+ return [...deduped.values()];
341
+ }
342
+
343
+ function toCommitActivity(repoPath: string, commit: CommitSnapshot): ActivityItem {
344
+ const repoName = path.basename(repoPath);
345
+ const shortSha = commit.sha.slice(0, 7);
346
+
347
+ return {
348
+ id: `git:commit:${sanitizeIdSegment(repoName)}:${commit.sha}`,
349
+ source: "git",
350
+ type: "commit",
351
+ title: `[${repoName}] ${commit.subject}`,
352
+ summary: createCommitSummary(repoName, commit.subject, commit.changedFiles),
353
+ timestamp: commit.timestamp,
354
+ tags: createTags(repoName, commit.subject, commit.changedFiles, ["commit"]),
355
+ weight: 3,
356
+ status: "done",
357
+ evidence: [
358
+ `${repoName}@${shortSha}`,
359
+ ...commit.changedFiles.slice(0, 4).map((filePath) => `${repoName}/${filePath}`)
360
+ ],
361
+ metadata: {
362
+ repoName,
363
+ commitSha: commit.sha,
364
+ changedFiles: commit.changedFiles,
365
+ changedFileCount: commit.changedFiles.length,
366
+ shortSha
367
+ }
368
+ };
369
+ }
370
+
371
+ function toPushActivity(repoPath: string, push: PushSnapshot, commit: CommitSnapshot | null): ActivityItem {
372
+ const repoName = path.basename(repoPath);
373
+ const branchName = push.shortRef.includes("/") ? push.shortRef.split("/").slice(1).join("/") : push.shortRef;
374
+ const shortSha = push.sha.slice(0, 7);
375
+ const changedFiles = commit?.changedFiles ?? [];
376
+ const subject = commit?.subject ?? `push to ${push.shortRef}`;
377
+
378
+ return {
379
+ id: `git:push:${sanitizeIdSegment(repoName)}:${sanitizeIdSegment(push.shortRef)}:${push.timestamp}:${push.sha}`,
380
+ source: "git",
381
+ type: "push",
382
+ title: `[${repoName}] ${branchName} push`,
383
+ summary: createPushSummary(repoName, push, commit),
384
+ timestamp: push.timestamp,
385
+ tags: createTags(repoName, subject, changedFiles, ["push", branchName]),
386
+ weight: 2.6,
387
+ status: "done",
388
+ evidence: [`${repoName}@${shortSha}`, push.shortRef, ...changedFiles.slice(0, 3).map((filePath) => `${repoName}/${filePath}`)],
389
+ metadata: {
390
+ repoName,
391
+ branchName,
392
+ remoteRef: push.shortRef,
393
+ pushMessage: push.message,
394
+ commitSha: push.sha,
395
+ relatedCommitTitle: commit?.subject ?? null,
396
+ changedFiles,
397
+ changedFileCount: changedFiles.length,
398
+ shortSha
399
+ }
400
+ };
401
+ }
402
+
403
+ async function collectRepoActivities(
404
+ repoPath: string,
405
+ sinceIso: string
406
+ ): Promise<ActivityItem[]> {
407
+ const commits = await loadCommitSnapshots(repoPath, sinceIso);
408
+ const commitMap = new Map(commits.map((commit) => [commit.sha, commit]));
409
+ const pushes = (await loadPushSnapshots(repoPath)).filter((push) => {
410
+ return new Date(push.timestamp).getTime() >= new Date(sinceIso).getTime();
411
+ });
412
+
413
+ const pushActivities: ActivityItem[] = [];
414
+ for (const push of pushes) {
415
+ let commit = commitMap.get(push.sha) ?? null;
416
+ if (!commit) {
417
+ commit = await loadCommitSnapshotForSha(repoPath, push.sha, push.timestamp);
418
+ if (commit) {
419
+ commitMap.set(commit.sha, commit);
420
+ }
421
+ }
422
+
423
+ pushActivities.push(toPushActivity(repoPath, push, commit));
424
+ }
425
+
426
+ return [...commits.map((commit) => toCommitActivity(repoPath, commit)), ...pushActivities];
427
+ }
428
+
429
+ export async function listLocalGitRepositories(config: AppConfig): Promise<string[]> {
430
+ const explicitRepos = config.localGitRepos;
431
+ const discoveredRepos = (
432
+ await Promise.all(config.localGitScanRoots.map((rootPath) => discoverGitRepos(rootPath, config.localGitScanDepth)))
433
+ ).flat();
434
+
435
+ const repoPaths = Array.from(new Set([...explicitRepos, ...discoveredRepos])).sort();
436
+ if (repoPaths.length === 0) {
437
+ logWarn("Local git collector found no repositories to inspect.");
438
+ return [];
439
+ }
440
+
441
+ return repoPaths;
442
+ }
443
+
444
+ export async function collectLocalGitActivities(
445
+ config: AppConfig,
446
+ now = new Date()
447
+ ): Promise<ActivityItem[]> {
448
+ const repoPaths = await listLocalGitRepositories(config);
449
+ if (repoPaths.length === 0) {
450
+ return [];
451
+ }
452
+
453
+ const sinceIso = subtractDays(now, config.recommendationWindowDays).toISOString();
454
+ logInfo(`Local git collector inspecting ${repoPaths.length} repositories.`);
455
+
456
+ const results = await Promise.allSettled(
457
+ repoPaths.map(async (repoPath) => collectRepoActivities(repoPath, sinceIso))
458
+ );
459
+
460
+ const activities: ActivityItem[] = [];
461
+ for (const [index, result] of results.entries()) {
462
+ if (result.status === "fulfilled") {
463
+ activities.push(...result.value);
464
+ continue;
465
+ }
466
+
467
+ logWarn(`Local git collector skipped ${repoPaths[index]}: ${String(result.reason)}`);
468
+ }
469
+
470
+ return activities;
471
+ }
package/src/config.ts ADDED
@@ -0,0 +1,225 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { existsSync } from "node:fs";
4
+
5
+ import { LAUNCH_AGENT_LABEL, resolveAppPaths, type AppPaths } from "./appPaths";
6
+ import type { LlmProvider } from "./types";
7
+ import { pathExists, readJsonFile, writeJsonFile } from "./utils/file";
8
+
9
+ export interface UserConfig {
10
+ llmProvider?: LlmProvider;
11
+ geminiApiKey?: string;
12
+ geminiApiKeySource?: "config" | "env";
13
+ geminiModel?: string;
14
+ codexHome?: string;
15
+ localGitRepos?: string[];
16
+ localGitScanRoots?: string[];
17
+ localGitScanDepth?: number;
18
+ schedule?: {
19
+ weekday?: number;
20
+ hour?: number;
21
+ };
22
+ topicCount?: number;
23
+ recommendationWindowDays?: number;
24
+ }
25
+
26
+ export interface AppConfig extends AppPaths {
27
+ packageName: string;
28
+ packageVersion: string;
29
+ configExists: boolean;
30
+ llmProvider: LlmProvider;
31
+ geminiApiKey?: string;
32
+ geminiApiKeySource: "config" | "env";
33
+ geminiModel: string;
34
+ codexHome: string;
35
+ codexSessionIndexPath: string;
36
+ codexLogsPath: string;
37
+ topicCount: number;
38
+ recommendationWindowDays: number;
39
+ scheduleHour: number;
40
+ scheduleWeekday: number;
41
+ localGitRepos: string[];
42
+ localGitScanRoots: string[];
43
+ localGitScanDepth: number;
44
+ launchAgentLabel: string;
45
+ }
46
+
47
+ interface PackageManifest {
48
+ name?: string;
49
+ version?: string;
50
+ }
51
+
52
+ function resolveHomePath(value: string | undefined, fallback: string): string {
53
+ const raw = value?.trim();
54
+ if (!raw) {
55
+ return fallback;
56
+ }
57
+
58
+ if (raw === "~") {
59
+ return os.homedir();
60
+ }
61
+
62
+ if (raw.startsWith("~/")) {
63
+ return path.join(os.homedir(), raw.slice(2));
64
+ }
65
+
66
+ return raw;
67
+ }
68
+
69
+ function readPositiveInteger(value: string | number | undefined, fallback: number): number {
70
+ const parsed =
71
+ typeof value === "number" ? value : Number.parseInt(typeof value === "string" ? value : "", 10);
72
+ if (!Number.isFinite(parsed) || parsed <= 0) {
73
+ return fallback;
74
+ }
75
+
76
+ return parsed;
77
+ }
78
+
79
+ function readWeekday(value: string | number | undefined, fallback: number): number {
80
+ const parsed =
81
+ typeof value === "number" ? value : Number.parseInt(typeof value === "string" ? value : "", 10);
82
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 6) {
83
+ return fallback;
84
+ }
85
+
86
+ return parsed;
87
+ }
88
+
89
+ function normalizePathInput(value: string, baseDir: string): string {
90
+ const resolved = resolveHomePath(value, baseDir);
91
+ return path.isAbsolute(resolved) ? path.normalize(resolved) : path.resolve(baseDir, resolved);
92
+ }
93
+
94
+ function normalizePathList(values: string[] | undefined, baseDir: string): string[] {
95
+ if (!Array.isArray(values)) {
96
+ return [];
97
+ }
98
+
99
+ return Array.from(
100
+ new Set(
101
+ values
102
+ .map((item) => item.trim())
103
+ .filter(Boolean)
104
+ .map((item) => normalizePathInput(item, baseDir))
105
+ )
106
+ );
107
+ }
108
+
109
+ function readEnvPathList(value: string | undefined, baseDir: string): string[] {
110
+ if (!value?.trim()) {
111
+ return [];
112
+ }
113
+
114
+ return normalizePathList(value.split(/[\n,]+/), baseDir);
115
+ }
116
+
117
+ async function loadPackageManifest(paths: AppPaths): Promise<PackageManifest> {
118
+ return readJsonFile<PackageManifest>(paths.packageManifestPath, {});
119
+ }
120
+
121
+ export async function loadUserConfig(paths = resolveAppPaths()): Promise<UserConfig> {
122
+ return readJsonFile<UserConfig>(paths.configPath, {});
123
+ }
124
+
125
+ export async function saveUserConfig(config: UserConfig, paths = resolveAppPaths()): Promise<void> {
126
+ await writeJsonFile(paths.configPath, config);
127
+ }
128
+
129
+ export function getDefaultLocalGitScanRoots(): string[] {
130
+ const home = os.homedir();
131
+ const candidates = [
132
+ path.join(home, "Documents", "work"),
133
+ path.join(home, "Documents"),
134
+ home
135
+ ];
136
+
137
+ const firstExisting = candidates.find((candidate) => existsSync(candidate));
138
+ return firstExisting ? [firstExisting] : [home];
139
+ }
140
+
141
+ function resolveLocalGitScanRoots(
142
+ userConfig: UserConfig,
143
+ baseDir: string
144
+ ): string[] {
145
+ const envValue = process.env.LOCAL_GIT_SCAN_ROOTS;
146
+ if (envValue?.trim()) {
147
+ const normalized = envValue.trim().toLowerCase();
148
+ if (normalized === "off" || normalized === "none" || normalized === "disabled") {
149
+ return [];
150
+ }
151
+
152
+ const fromEnv = readEnvPathList(envValue, baseDir);
153
+ if (fromEnv.length > 0) {
154
+ return fromEnv;
155
+ }
156
+ }
157
+
158
+ const fromConfig = normalizePathList(userConfig.localGitScanRoots, baseDir);
159
+ if (fromConfig.length > 0) {
160
+ return fromConfig;
161
+ }
162
+
163
+ return getDefaultLocalGitScanRoots().map((candidate) => normalizePathInput(candidate, baseDir));
164
+ }
165
+
166
+ export async function loadConfig(): Promise<AppConfig> {
167
+ const paths = resolveAppPaths();
168
+ const packageManifest = await loadPackageManifest(paths);
169
+ const userConfig = await loadUserConfig(paths);
170
+ const configExists = await pathExists(paths.configPath);
171
+
172
+ const llmProvider =
173
+ process.env.LLM_PROVIDER?.trim().toLowerCase() === "placeholder"
174
+ ? "placeholder"
175
+ : userConfig.llmProvider ?? "gemini";
176
+ const geminiApiKeyFromEnv =
177
+ process.env.GEMINI_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || undefined;
178
+ const geminiApiKey = geminiApiKeyFromEnv || userConfig.geminiApiKey || undefined;
179
+ const geminiApiKeySource: "config" | "env" = geminiApiKeyFromEnv
180
+ ? "env"
181
+ : userConfig.geminiApiKey
182
+ ? "config"
183
+ : userConfig.geminiApiKeySource ?? "env";
184
+ const codexHome = normalizePathInput(
185
+ process.env.CODEX_HOME?.trim() || userConfig.codexHome || path.join(os.homedir(), ".codex"),
186
+ paths.packageRoot
187
+ );
188
+
189
+ return {
190
+ ...paths,
191
+ packageName: packageManifest.name?.trim() || "harulog",
192
+ packageVersion: packageManifest.version?.trim() || "0.0.0",
193
+ configExists,
194
+ llmProvider,
195
+ geminiApiKey,
196
+ geminiApiKeySource,
197
+ geminiModel:
198
+ process.env.GEMINI_MODEL?.trim() ||
199
+ userConfig.geminiModel?.trim() ||
200
+ "gemini-2.5-flash",
201
+ codexHome,
202
+ codexSessionIndexPath: path.join(codexHome, "session_index.jsonl"),
203
+ codexLogsPath: path.join(codexHome, "logs_1.sqlite"),
204
+ topicCount: readPositiveInteger(process.env.TOPIC_COUNT ?? userConfig.topicCount, 5),
205
+ recommendationWindowDays: readPositiveInteger(
206
+ process.env.RECOMMENDATION_WINDOW_DAYS ?? userConfig.recommendationWindowDays,
207
+ 14
208
+ ),
209
+ scheduleHour: readPositiveInteger(process.env.SCHEDULE_HOUR ?? userConfig.schedule?.hour, 9),
210
+ scheduleWeekday: readWeekday(
211
+ process.env.SCHEDULE_WEEKDAY ?? userConfig.schedule?.weekday,
212
+ 5
213
+ ),
214
+ localGitRepos:
215
+ readEnvPathList(process.env.LOCAL_GIT_REPOS, paths.packageRoot).length > 0
216
+ ? readEnvPathList(process.env.LOCAL_GIT_REPOS, paths.packageRoot)
217
+ : normalizePathList(userConfig.localGitRepos, paths.packageRoot),
218
+ localGitScanRoots: resolveLocalGitScanRoots(userConfig, paths.packageRoot),
219
+ localGitScanDepth: readPositiveInteger(
220
+ process.env.LOCAL_GIT_SCAN_DEPTH ?? userConfig.localGitScanDepth,
221
+ 2
222
+ ),
223
+ launchAgentLabel: LAUNCH_AGENT_LABEL
224
+ };
225
+ }