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.
- package/.env.example +14 -0
- package/README.md +111 -0
- package/package.json +32 -0
- package/src/appPaths.ts +67 -0
- package/src/cli.ts +120 -0
- package/src/collectors/codex.ts +72 -0
- package/src/collectors/github.ts +8 -0
- package/src/collectors/localGit.ts +471 -0
- package/src/config.ts +225 -0
- package/src/doctor.ts +63 -0
- package/src/index.ts +22 -0
- package/src/jobs/dailyTopics.ts +10 -0
- package/src/normalize/activity.ts +43 -0
- package/src/prompts/topicPrompt.ts +142 -0
- package/src/recommend/topics.ts +404 -0
- package/src/scheduler.ts +153 -0
- package/src/services/topicsJob.ts +134 -0
- package/src/setup.ts +147 -0
- package/src/storage/history.ts +28 -0
- package/src/storage/state.ts +82 -0
- package/src/types.ts +61 -0
- package/src/utils/date.ts +47 -0
- package/src/utils/file.ts +47 -0
- package/src/utils/logger.ts +7 -0
- package/src/utils/markdown.ts +53 -0
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
import type { AppConfig } from "./config";
|
|
5
|
+
import { ensureDir, pathExists, removeFileIfExists, writeExecutableFile, writeTextFile } from "./utils/file";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export interface SchedulerStatus {
|
|
10
|
+
installed: boolean;
|
|
11
|
+
loaded: boolean;
|
|
12
|
+
plistPath: string;
|
|
13
|
+
wrapperPath: string;
|
|
14
|
+
logPath: string;
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SchedulerInstallOptions {
|
|
19
|
+
writeOnly?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildWrapperScript(config: AppConfig): string {
|
|
23
|
+
return [
|
|
24
|
+
"#!/bin/zsh",
|
|
25
|
+
"",
|
|
26
|
+
"set -euo pipefail",
|
|
27
|
+
"",
|
|
28
|
+
`export HARULOG_HOME="${config.appHome}"`,
|
|
29
|
+
`BUN_PATH="${process.execPath}"`,
|
|
30
|
+
`PACKAGE_SPEC="${config.packageName}@${config.packageVersion}"`,
|
|
31
|
+
"",
|
|
32
|
+
'if [[ ! -x "$BUN_PATH" ]]; then',
|
|
33
|
+
' BUN_PATH="$(command -v bun || true)"',
|
|
34
|
+
"fi",
|
|
35
|
+
"",
|
|
36
|
+
'if [[ -z "$BUN_PATH" ]]; then',
|
|
37
|
+
' echo "bun executable not found" >&2',
|
|
38
|
+
" exit 1",
|
|
39
|
+
"fi",
|
|
40
|
+
"",
|
|
41
|
+
'exec "$BUN_PATH" x --bun "$PACKAGE_SPEC" run --scheduled'
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildLaunchAgentPlist(config: AppConfig): string {
|
|
46
|
+
return [
|
|
47
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
48
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
49
|
+
'<plist version="1.0">',
|
|
50
|
+
" <dict>",
|
|
51
|
+
" <key>Label</key>",
|
|
52
|
+
` <string>${config.launchAgentLabel}</string>`,
|
|
53
|
+
" <key>ProgramArguments</key>",
|
|
54
|
+
" <array>",
|
|
55
|
+
` <string>${config.schedulerWrapperPath}</string>`,
|
|
56
|
+
" </array>",
|
|
57
|
+
" <key>WorkingDirectory</key>",
|
|
58
|
+
` <string>${config.appHome}</string>`,
|
|
59
|
+
" <key>RunAtLoad</key>",
|
|
60
|
+
" <true/>",
|
|
61
|
+
" <key>StartCalendarInterval</key>",
|
|
62
|
+
" <dict>",
|
|
63
|
+
" <key>Weekday</key>",
|
|
64
|
+
` <integer>${config.scheduleWeekday}</integer>`,
|
|
65
|
+
" <key>Hour</key>",
|
|
66
|
+
` <integer>${config.scheduleHour}</integer>`,
|
|
67
|
+
" <key>Minute</key>",
|
|
68
|
+
" <integer>0</integer>",
|
|
69
|
+
" </dict>",
|
|
70
|
+
" <key>StandardOutPath</key>",
|
|
71
|
+
` <string>${config.launchAgentLogPath}</string>`,
|
|
72
|
+
" <key>StandardErrorPath</key>",
|
|
73
|
+
` <string>${config.launchAgentLogPath}</string>`,
|
|
74
|
+
" </dict>",
|
|
75
|
+
"</plist>",
|
|
76
|
+
""
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getLaunchCtlDomain(config: AppConfig): string {
|
|
81
|
+
if (typeof process.getuid !== "function") {
|
|
82
|
+
throw new Error(`launchctl integration requires a POSIX user id for ${config.launchAgentLabel}.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return `gui/${process.getuid()}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function isLaunchAgentLoaded(config: AppConfig): Promise<boolean> {
|
|
89
|
+
if (process.platform !== "darwin") {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await execFileAsync("launchctl", ["print", `${getLaunchCtlDomain(config)}/${config.launchAgentLabel}`]);
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function getSchedulerStatus(config: AppConfig): Promise<SchedulerStatus> {
|
|
102
|
+
return {
|
|
103
|
+
installed: await pathExists(config.launchAgentPath),
|
|
104
|
+
loaded: await isLaunchAgentLoaded(config),
|
|
105
|
+
plistPath: config.launchAgentPath,
|
|
106
|
+
wrapperPath: config.schedulerWrapperPath,
|
|
107
|
+
logPath: config.launchAgentLogPath,
|
|
108
|
+
label: config.launchAgentLabel
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function installScheduler(
|
|
113
|
+
config: AppConfig,
|
|
114
|
+
options: SchedulerInstallOptions = {}
|
|
115
|
+
): Promise<SchedulerStatus> {
|
|
116
|
+
await ensureDir(config.binDir);
|
|
117
|
+
await ensureDir(config.logsDir);
|
|
118
|
+
await ensureDir(config.launchAgentsDir);
|
|
119
|
+
|
|
120
|
+
await writeExecutableFile(config.schedulerWrapperPath, buildWrapperScript(config));
|
|
121
|
+
await writeTextFile(config.launchAgentPath, buildLaunchAgentPlist(config));
|
|
122
|
+
|
|
123
|
+
if (!options.writeOnly && process.platform === "darwin") {
|
|
124
|
+
const launchCtlDomain = getLaunchCtlDomain(config);
|
|
125
|
+
try {
|
|
126
|
+
await execFileAsync("launchctl", ["bootout", launchCtlDomain, config.launchAgentPath]);
|
|
127
|
+
} catch {
|
|
128
|
+
// best effort
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await execFileAsync("launchctl", ["bootstrap", launchCtlDomain, config.launchAgentPath]);
|
|
132
|
+
await execFileAsync("launchctl", ["enable", `${launchCtlDomain}/${config.launchAgentLabel}`]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return getSchedulerStatus(config);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function uninstallScheduler(
|
|
139
|
+
config: AppConfig,
|
|
140
|
+
options: SchedulerInstallOptions = {}
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
if (!options.writeOnly && process.platform === "darwin") {
|
|
143
|
+
const launchCtlDomain = getLaunchCtlDomain(config);
|
|
144
|
+
try {
|
|
145
|
+
await execFileAsync("launchctl", ["bootout", launchCtlDomain, config.launchAgentPath]);
|
|
146
|
+
} catch {
|
|
147
|
+
// best effort
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await removeFileIfExists(config.launchAgentPath);
|
|
152
|
+
await removeFileIfExists(config.schedulerWrapperPath);
|
|
153
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { collectCodexActivities } from "../collectors/codex";
|
|
4
|
+
import { collectLocalGitActivities } from "../collectors/localGit";
|
|
5
|
+
import { loadConfig, type AppConfig } from "../config";
|
|
6
|
+
import { normalizeActivities } from "../normalize/activity";
|
|
7
|
+
import { createTopicRecommender } from "../recommend/topics";
|
|
8
|
+
import { loadTopicHistory, mergeTopicHistory, saveTopicHistory } from "../storage/history";
|
|
9
|
+
import { loadRuntimeState, saveRuntimeState, shouldRunScheduledJob } from "../storage/state";
|
|
10
|
+
import type { DailyJobResult } from "../types";
|
|
11
|
+
import { formatLocalDateKey } from "../utils/date";
|
|
12
|
+
import { ensureDir, writeJsonFile, writeTextFile } from "../utils/file";
|
|
13
|
+
import { logInfo } from "../utils/logger";
|
|
14
|
+
import { renderTopicsMarkdown } from "../utils/markdown";
|
|
15
|
+
|
|
16
|
+
export interface RunTopicsJobOptions {
|
|
17
|
+
force?: boolean;
|
|
18
|
+
ignoreHistory?: boolean;
|
|
19
|
+
scheduled?: boolean;
|
|
20
|
+
now?: Date;
|
|
21
|
+
config?: AppConfig;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildOutputPaths(outputsDir: string, now: Date): { jsonPath: string; markdownPath: string } {
|
|
25
|
+
const dateKey = formatLocalDateKey(now);
|
|
26
|
+
return {
|
|
27
|
+
jsonPath: path.join(outputsDir, `${dateKey}-topics.json`),
|
|
28
|
+
markdownPath: path.join(outputsDir, `${dateKey}-topics.md`)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runTopicsJob(options: RunTopicsJobOptions = {}): Promise<DailyJobResult> {
|
|
33
|
+
const config = options.config ?? (await loadConfig());
|
|
34
|
+
const now = options.now ?? new Date();
|
|
35
|
+
const force = options.force === true;
|
|
36
|
+
const ignoreHistory = options.ignoreHistory === true;
|
|
37
|
+
const scheduled = options.scheduled === true;
|
|
38
|
+
|
|
39
|
+
await ensureDir(config.appHome);
|
|
40
|
+
await ensureDir(config.outputsDir);
|
|
41
|
+
await ensureDir(config.logsDir);
|
|
42
|
+
await ensureDir(config.binDir);
|
|
43
|
+
|
|
44
|
+
let decision = {
|
|
45
|
+
shouldRun: true,
|
|
46
|
+
reason: force ? "Forced manual execution requested." : "Manual run requested."
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (scheduled) {
|
|
50
|
+
const state = await loadRuntimeState(config.statePath);
|
|
51
|
+
decision = shouldRunScheduledJob(
|
|
52
|
+
now,
|
|
53
|
+
config.scheduleWeekday,
|
|
54
|
+
config.scheduleHour,
|
|
55
|
+
state,
|
|
56
|
+
force
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!decision.shouldRun) {
|
|
61
|
+
logInfo(decision.reason);
|
|
62
|
+
return {
|
|
63
|
+
skipped: true,
|
|
64
|
+
reason: decision.reason,
|
|
65
|
+
suggestions: []
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
logInfo(decision.reason);
|
|
70
|
+
|
|
71
|
+
const [codexActivities, gitActivities] = await Promise.all([
|
|
72
|
+
collectCodexActivities(config),
|
|
73
|
+
collectLocalGitActivities(config, now)
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const activities = normalizeActivities(
|
|
77
|
+
[...codexActivities, ...gitActivities],
|
|
78
|
+
now,
|
|
79
|
+
config.recommendationWindowDays
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (ignoreHistory) {
|
|
83
|
+
logInfo("Ignoring topic history for this run.");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const history = ignoreHistory ? [] : await loadTopicHistory(config.topicHistoryPath);
|
|
87
|
+
const recommender = createTopicRecommender(config);
|
|
88
|
+
const suggestions = await recommender.recommend({
|
|
89
|
+
activities,
|
|
90
|
+
history,
|
|
91
|
+
count: config.topicCount,
|
|
92
|
+
now
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const outputPaths = buildOutputPaths(config.outputsDir, now);
|
|
96
|
+
const outputPayload = {
|
|
97
|
+
generatedAt: now.toISOString(),
|
|
98
|
+
llmProvider: config.llmProvider,
|
|
99
|
+
model: config.llmProvider === "gemini" ? config.geminiModel : "placeholder",
|
|
100
|
+
activitiesConsidered: activities.length,
|
|
101
|
+
suggestions
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
await writeJsonFile(outputPaths.jsonPath, outputPayload);
|
|
105
|
+
await writeTextFile(
|
|
106
|
+
outputPaths.markdownPath,
|
|
107
|
+
renderTopicsMarkdown({
|
|
108
|
+
generatedAt: now.toISOString(),
|
|
109
|
+
llmProvider: config.llmProvider,
|
|
110
|
+
model: config.llmProvider === "gemini" ? config.geminiModel : "placeholder",
|
|
111
|
+
activities,
|
|
112
|
+
suggestions
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (!ignoreHistory) {
|
|
117
|
+
const nextHistory = mergeTopicHistory(history, suggestions, now);
|
|
118
|
+
await saveTopicHistory(config.topicHistoryPath, nextHistory);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await saveRuntimeState(config.statePath, {
|
|
122
|
+
lastRunAt: now.toISOString(),
|
|
123
|
+
lastOutputJsonPath: outputPaths.jsonPath,
|
|
124
|
+
lastOutputMarkdownPath: outputPaths.markdownPath
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
logInfo(`Saved ${suggestions.length} suggestions to ${outputPaths.jsonPath}`);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
skipped: false,
|
|
131
|
+
reason: decision.reason,
|
|
132
|
+
suggestions
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
|
|
6
|
+
import { loadConfig, loadUserConfig, saveUserConfig, type UserConfig } from "./config";
|
|
7
|
+
import { installScheduler } from "./scheduler";
|
|
8
|
+
import { ensureDir } from "./utils/file";
|
|
9
|
+
|
|
10
|
+
export interface SetupOptions {
|
|
11
|
+
defaults?: boolean;
|
|
12
|
+
installScheduler?: boolean;
|
|
13
|
+
skipScheduler?: boolean;
|
|
14
|
+
writeOnlyScheduler?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function splitCsv(value: string): string[] {
|
|
18
|
+
return value
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((item) => item.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function askText(
|
|
25
|
+
rl: ReturnType<typeof createInterface>,
|
|
26
|
+
label: string,
|
|
27
|
+
defaultValue: string
|
|
28
|
+
): Promise<string> {
|
|
29
|
+
const answer = (await rl.question(`${label}${defaultValue ? ` [${defaultValue}]` : ""}: `)).trim();
|
|
30
|
+
return answer || defaultValue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function askYesNo(
|
|
34
|
+
rl: ReturnType<typeof createInterface>,
|
|
35
|
+
label: string,
|
|
36
|
+
defaultValue: boolean
|
|
37
|
+
): Promise<boolean> {
|
|
38
|
+
const suffix = defaultValue ? "Y/n" : "y/N";
|
|
39
|
+
const answer = (await rl.question(`${label} [${suffix}]: `)).trim().toLowerCase();
|
|
40
|
+
if (!answer) {
|
|
41
|
+
return defaultValue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return answer === "y" || answer === "yes";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runSetupWizard(options: SetupOptions = {}): Promise<void> {
|
|
48
|
+
const current = await loadUserConfig();
|
|
49
|
+
const defaults = await loadConfig();
|
|
50
|
+
const defaultCodexHome = current.codexHome || path.join(os.homedir(), ".codex");
|
|
51
|
+
const defaultRepos = (current.localGitRepos ?? []).join(", ");
|
|
52
|
+
const defaultScanRoots =
|
|
53
|
+
current.localGitScanRoots && current.localGitScanRoots.length > 0
|
|
54
|
+
? current.localGitScanRoots.join(", ")
|
|
55
|
+
: defaults.localGitScanRoots.join(", ");
|
|
56
|
+
const defaultScanDepth = String(current.localGitScanDepth ?? defaults.localGitScanDepth);
|
|
57
|
+
const defaultWeekday = String(current.schedule?.weekday ?? defaults.scheduleWeekday);
|
|
58
|
+
const defaultHour = String(current.schedule?.hour ?? defaults.scheduleHour);
|
|
59
|
+
const defaultTopicCount = String(current.topicCount ?? defaults.topicCount);
|
|
60
|
+
const defaultWindowDays = String(
|
|
61
|
+
current.recommendationWindowDays ?? defaults.recommendationWindowDays
|
|
62
|
+
);
|
|
63
|
+
let shouldInstallScheduler = options.installScheduler === true;
|
|
64
|
+
if (options.skipScheduler === true) {
|
|
65
|
+
shouldInstallScheduler = false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const nextConfig: UserConfig = {
|
|
69
|
+
llmProvider: "gemini",
|
|
70
|
+
geminiModel: current.geminiModel ?? defaults.geminiModel,
|
|
71
|
+
geminiApiKey: current.geminiApiKey,
|
|
72
|
+
geminiApiKeySource: current.geminiApiKey ? current.geminiApiKeySource ?? "config" : "env",
|
|
73
|
+
codexHome: defaultCodexHome,
|
|
74
|
+
localGitRepos: splitCsv(defaultRepos),
|
|
75
|
+
localGitScanRoots: splitCsv(defaultScanRoots),
|
|
76
|
+
localGitScanDepth: Number.parseInt(defaultScanDepth, 10),
|
|
77
|
+
schedule: {
|
|
78
|
+
weekday: Number.parseInt(defaultWeekday, 10),
|
|
79
|
+
hour: Number.parseInt(defaultHour, 10)
|
|
80
|
+
},
|
|
81
|
+
topicCount: Number.parseInt(defaultTopicCount, 10),
|
|
82
|
+
recommendationWindowDays: Number.parseInt(defaultWindowDays, 10)
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (!options.defaults) {
|
|
86
|
+
if (!input.isTTY || !output.isTTY) {
|
|
87
|
+
throw new Error("Interactive setup requires a TTY. Re-run in a terminal or use --defaults.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rl = createInterface({ input, output });
|
|
91
|
+
try {
|
|
92
|
+
console.log("harulog setup");
|
|
93
|
+
console.log("Leave the Gemini key blank to keep the current value or use GEMINI_API_KEY from env.");
|
|
94
|
+
console.log("");
|
|
95
|
+
|
|
96
|
+
const geminiApiKeyInput = await askText(rl, "Gemini API key", "");
|
|
97
|
+
if (geminiApiKeyInput) {
|
|
98
|
+
nextConfig.geminiApiKey = geminiApiKeyInput;
|
|
99
|
+
nextConfig.geminiApiKeySource = "config";
|
|
100
|
+
} else if (!nextConfig.geminiApiKey) {
|
|
101
|
+
nextConfig.geminiApiKeySource = "env";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
nextConfig.geminiModel = await askText(rl, "Gemini model", nextConfig.geminiModel ?? defaults.geminiModel);
|
|
105
|
+
nextConfig.codexHome = await askText(rl, "Codex home", defaultCodexHome);
|
|
106
|
+
nextConfig.localGitRepos = splitCsv(await askText(rl, "Tracked git repos (comma-separated, optional)", defaultRepos));
|
|
107
|
+
nextConfig.localGitScanRoots = splitCsv(
|
|
108
|
+
await askText(rl, "Git scan roots (comma-separated)", defaultScanRoots)
|
|
109
|
+
);
|
|
110
|
+
nextConfig.localGitScanDepth = Number.parseInt(
|
|
111
|
+
await askText(rl, "Git scan depth", defaultScanDepth),
|
|
112
|
+
10
|
|
113
|
+
);
|
|
114
|
+
nextConfig.schedule = {
|
|
115
|
+
weekday: Number.parseInt(await askText(rl, "Schedule weekday (0=Sun, 5=Fri)", defaultWeekday), 10),
|
|
116
|
+
hour: Number.parseInt(await askText(rl, "Schedule hour (0-23)", defaultHour), 10)
|
|
117
|
+
};
|
|
118
|
+
nextConfig.topicCount = Number.parseInt(await askText(rl, "Topic count", defaultTopicCount), 10);
|
|
119
|
+
nextConfig.recommendationWindowDays = Number.parseInt(
|
|
120
|
+
await askText(rl, "Recommendation window days", defaultWindowDays),
|
|
121
|
+
10
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
if (options.installScheduler === undefined && options.skipScheduler === undefined) {
|
|
125
|
+
shouldInstallScheduler = await askYesNo(rl, "Install launchd scheduler now?", true);
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
rl.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await ensureDir(defaults.appHome);
|
|
133
|
+
await ensureDir(defaults.outputsDir);
|
|
134
|
+
await ensureDir(defaults.logsDir);
|
|
135
|
+
await ensureDir(defaults.binDir);
|
|
136
|
+
await saveUserConfig(nextConfig);
|
|
137
|
+
|
|
138
|
+
const resolved = await loadConfig();
|
|
139
|
+
console.log(`Saved config to ${resolved.configPath}`);
|
|
140
|
+
|
|
141
|
+
if (shouldInstallScheduler) {
|
|
142
|
+
const status = await installScheduler(resolved, {
|
|
143
|
+
writeOnly: options.writeOnlyScheduler === true
|
|
144
|
+
});
|
|
145
|
+
console.log(`Scheduler installed: ${status.plistPath}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TopicHistoryItem, TopicSuggestion } from "../types";
|
|
2
|
+
import { readJsonFile, writeJsonFile } from "../utils/file";
|
|
3
|
+
|
|
4
|
+
export async function loadTopicHistory(filePath: string): Promise<TopicHistoryItem[]> {
|
|
5
|
+
return readJsonFile<TopicHistoryItem[]>(filePath, []);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function saveTopicHistory(filePath: string, history: TopicHistoryItem[]): Promise<void> {
|
|
9
|
+
await writeJsonFile(filePath, history);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function mergeTopicHistory(
|
|
13
|
+
history: TopicHistoryItem[],
|
|
14
|
+
suggestions: TopicSuggestion[],
|
|
15
|
+
generatedAt: Date
|
|
16
|
+
): TopicHistoryItem[] {
|
|
17
|
+
const merged = [...history];
|
|
18
|
+
|
|
19
|
+
for (const suggestion of suggestions) {
|
|
20
|
+
merged.push({
|
|
21
|
+
title: suggestion.title,
|
|
22
|
+
createdAt: generatedAt.toISOString(),
|
|
23
|
+
relatedKeywords: suggestion.keywords
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return merged.slice(-100);
|
|
28
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { RuntimeState } from "../types";
|
|
2
|
+
import {
|
|
3
|
+
formatWeekdayLabel,
|
|
4
|
+
getMostRecentScheduledMomentForWeekday,
|
|
5
|
+
getScheduledMomentForWeekday,
|
|
6
|
+
isOnOrAfter
|
|
7
|
+
} from "../utils/date";
|
|
8
|
+
import { readJsonFile, writeJsonFile } from "../utils/file";
|
|
9
|
+
|
|
10
|
+
export interface ScheduledRunDecision {
|
|
11
|
+
shouldRun: boolean;
|
|
12
|
+
reason: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function loadRuntimeState(filePath: string): Promise<RuntimeState> {
|
|
16
|
+
return readJsonFile<RuntimeState>(filePath, {});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function saveRuntimeState(filePath: string, state: RuntimeState): Promise<void> {
|
|
20
|
+
await writeJsonFile(filePath, state);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildScheduleLabel(weekday: number, scheduleHour: number): string {
|
|
24
|
+
return `${formatWeekdayLabel(weekday)} ${String(scheduleHour).padStart(2, "0")}:00`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function shouldRunScheduledJob(
|
|
28
|
+
now: Date,
|
|
29
|
+
scheduleWeekday: number,
|
|
30
|
+
scheduleHour: number,
|
|
31
|
+
state: RuntimeState,
|
|
32
|
+
force = false
|
|
33
|
+
): ScheduledRunDecision {
|
|
34
|
+
if (force) {
|
|
35
|
+
return {
|
|
36
|
+
shouldRun: true,
|
|
37
|
+
reason: "Forced execution requested."
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const currentWeekScheduledMoment = getScheduledMomentForWeekday(now, scheduleWeekday, scheduleHour);
|
|
42
|
+
const mostRecentScheduledMoment = getMostRecentScheduledMomentForWeekday(
|
|
43
|
+
now,
|
|
44
|
+
scheduleWeekday,
|
|
45
|
+
scheduleHour
|
|
46
|
+
);
|
|
47
|
+
const scheduleLabel = buildScheduleLabel(scheduleWeekday, scheduleHour);
|
|
48
|
+
|
|
49
|
+
if (!state.lastRunAt) {
|
|
50
|
+
if (!isOnOrAfter(now, currentWeekScheduledMoment)) {
|
|
51
|
+
return {
|
|
52
|
+
shouldRun: false,
|
|
53
|
+
reason: `Current time is before this week's scheduled run (${scheduleLabel}).`
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
shouldRun: true,
|
|
59
|
+
reason: `No previous run found and this week's scheduled time has passed (${scheduleLabel}).`
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lastRunAt = new Date(state.lastRunAt);
|
|
64
|
+
if (!Number.isFinite(lastRunAt.getTime())) {
|
|
65
|
+
return {
|
|
66
|
+
shouldRun: true,
|
|
67
|
+
reason: "Stored state is invalid."
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (isOnOrAfter(lastRunAt, mostRecentScheduledMoment)) {
|
|
72
|
+
return {
|
|
73
|
+
shouldRun: false,
|
|
74
|
+
reason: `Job already ran for the latest scheduled window (${scheduleLabel}).`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
shouldRun: true,
|
|
80
|
+
reason: `Latest scheduled window passed and no run has been recorded for it (${scheduleLabel}).`
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type JsonPrimitive = string | number | boolean | null;
|
|
2
|
+
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
|
3
|
+
|
|
4
|
+
export type ActivitySource = "github" | "git" | "codex";
|
|
5
|
+
export type ActivityStatus = "done" | "planned" | "reference";
|
|
6
|
+
export type LlmProvider = "gemini" | "placeholder";
|
|
7
|
+
|
|
8
|
+
export interface ActivityItem {
|
|
9
|
+
id: string;
|
|
10
|
+
source: ActivitySource;
|
|
11
|
+
type: string;
|
|
12
|
+
title: string;
|
|
13
|
+
summary: string;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
url?: string;
|
|
16
|
+
tags: string[];
|
|
17
|
+
weight: number;
|
|
18
|
+
status: ActivityStatus;
|
|
19
|
+
evidence: string[];
|
|
20
|
+
metadata?: Record<string, JsonValue>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TopicSuggestion {
|
|
24
|
+
title: string;
|
|
25
|
+
reason: string;
|
|
26
|
+
audience: string;
|
|
27
|
+
outline: string[];
|
|
28
|
+
draft: string;
|
|
29
|
+
keywords: string[];
|
|
30
|
+
evidenceIds: string[];
|
|
31
|
+
score: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TopicHistoryItem {
|
|
35
|
+
title: string;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
relatedKeywords: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RuntimeState {
|
|
41
|
+
lastRunAt?: string;
|
|
42
|
+
lastOutputJsonPath?: string;
|
|
43
|
+
lastOutputMarkdownPath?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DailyJobResult {
|
|
47
|
+
skipped: boolean;
|
|
48
|
+
reason: string;
|
|
49
|
+
suggestions: TopicSuggestion[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RecommendInput {
|
|
53
|
+
activities: ActivityItem[];
|
|
54
|
+
history: TopicHistoryItem[];
|
|
55
|
+
count: number;
|
|
56
|
+
now: Date;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TopicRecommender {
|
|
60
|
+
recommend(input: RecommendInput): Promise<TopicSuggestion[]>;
|
|
61
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function formatLocalDateKey(date: Date): string {
|
|
2
|
+
const year = date.getFullYear();
|
|
3
|
+
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
|
4
|
+
const day = `${date.getDate()}`.padStart(2, "0");
|
|
5
|
+
return `${year}-${month}-${day}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatWeekdayLabel(weekday: number): string {
|
|
9
|
+
const labels = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
10
|
+
return labels[weekday] ?? `weekday-${weekday}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function subtractDays(date: Date, days: number): Date {
|
|
14
|
+
const next = new Date(date);
|
|
15
|
+
next.setDate(next.getDate() - days);
|
|
16
|
+
return next;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getScheduledMoment(date: Date, hour: number): Date {
|
|
20
|
+
const next = new Date(date);
|
|
21
|
+
next.setHours(hour, 0, 0, 0);
|
|
22
|
+
return next;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getScheduledMomentForWeekday(date: Date, weekday: number, hour: number): Date {
|
|
26
|
+
const next = new Date(date);
|
|
27
|
+
const dayOffset = weekday - next.getDay();
|
|
28
|
+
next.setDate(next.getDate() + dayOffset);
|
|
29
|
+
next.setHours(hour, 0, 0, 0);
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getMostRecentScheduledMomentForWeekday(
|
|
34
|
+
date: Date,
|
|
35
|
+
weekday: number,
|
|
36
|
+
hour: number
|
|
37
|
+
): Date {
|
|
38
|
+
const next = getScheduledMomentForWeekday(date, weekday, hour);
|
|
39
|
+
if (next.getTime() > date.getTime()) {
|
|
40
|
+
next.setDate(next.getDate() - 7);
|
|
41
|
+
}
|
|
42
|
+
return next;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isOnOrAfter(left: Date, right: Date): boolean {
|
|
46
|
+
return left.getTime() >= right.getTime();
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { access, chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export async function ensureDir(dirPath: string): Promise<void> {
|
|
5
|
+
await mkdir(dirPath, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function pathExists(targetPath: string): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
await access(targetPath);
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
|
|
18
|
+
try {
|
|
19
|
+
const content = await readFile(filePath, "utf8");
|
|
20
|
+
return JSON.parse(content) as T;
|
|
21
|
+
} catch {
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
|
27
|
+
await ensureDir(path.dirname(filePath));
|
|
28
|
+
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function writeTextFile(filePath: string, content: string): Promise<void> {
|
|
32
|
+
await ensureDir(path.dirname(filePath));
|
|
33
|
+
await writeFile(filePath, content, "utf8");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function writeExecutableFile(filePath: string, content: string): Promise<void> {
|
|
37
|
+
await writeTextFile(filePath, content);
|
|
38
|
+
await chmod(filePath, 0o755);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function removeFileIfExists(filePath: string): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
await rm(filePath, { force: true });
|
|
44
|
+
} catch {
|
|
45
|
+
// best effort
|
|
46
|
+
}
|
|
47
|
+
}
|