@openmnemo/sync 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 openmnemo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,112 @@
1
+ import { ParsedTranscript } from '@openmnemo/types';
2
+
3
+ /**
4
+ * Load, validate, and manage ~/.memorytree/config.toml.
5
+ * Port of scripts/_config_utils.py
6
+ */
7
+ interface ProjectEntry {
8
+ readonly path: string;
9
+ readonly name: string;
10
+ }
11
+ interface Config {
12
+ readonly heartbeat_interval: string;
13
+ readonly watch_dirs: readonly string[];
14
+ readonly projects: readonly ProjectEntry[];
15
+ readonly auto_push: boolean;
16
+ readonly log_level: string;
17
+ }
18
+ declare function memorytreeRoot(): string;
19
+ declare function configPath(): string;
20
+ declare function loadConfig(): Config;
21
+ declare function saveConfig(cfg: Config): void;
22
+ declare function intervalToSeconds(interval: string): number;
23
+ declare function registerProject(cfg: Config, repoPath: string): Config;
24
+
25
+ /**
26
+ * MemoryTree heartbeat — single execution, stateless, idempotent.
27
+ * Port of scripts/heartbeat.py
28
+ */
29
+
30
+ declare function main(): Promise<number>;
31
+ declare function runHeartbeat(config: Config): Promise<number>;
32
+ declare function processProject(config: Config, projectPath: string, projectName: string): Promise<void>;
33
+ declare function scanSensitive(parsed: ParsedTranscript, projectPath: string): void;
34
+ declare function gitCommitAndPush(config: Config, projectPath: string, projectName: string, count: number): void;
35
+ declare function tryPush(projectPath: string, projectName: string): boolean;
36
+
37
+ /**
38
+ * PID-based lock file for heartbeat single-instance enforcement.
39
+ * Port of scripts/_lock_utils.py
40
+ */
41
+ declare function lockPath(): string;
42
+ /**
43
+ * Try to acquire the heartbeat lock. Returns `true` on success.
44
+ *
45
+ * If the lock file already exists, the stored PID is checked:
46
+ * - alive process -> return false (already running)
47
+ * - dead process -> reclaim the stale lock
48
+ * - corrupt file -> remove and retry
49
+ *
50
+ * Uses `O_CREAT | O_EXCL` to prevent TOCTOU races.
51
+ */
52
+ declare function acquireLock(): boolean;
53
+ /** Release the heartbeat lock. */
54
+ declare function releaseLock(): void;
55
+ /** Read the PID from the lock file, or `null` if not locked / corrupt. */
56
+ declare function readLockPid(): number | null;
57
+ /** Check whether a process with the given PID is still running. */
58
+ declare function isProcessAlive(pid: number): boolean;
59
+
60
+ /**
61
+ * Manage ~/.memorytree/alerts.json — append, dedup, threshold, display.
62
+ * Port of scripts/_alert_utils.py
63
+ */
64
+ declare const MAX_ALERTS = 100;
65
+ declare const ALERT_TYPES: ReadonlySet<string>;
66
+ declare const FAILURE_THRESHOLD = 3;
67
+ interface Alert {
68
+ readonly timestamp: string;
69
+ readonly project: string;
70
+ readonly type: string;
71
+ readonly message: string;
72
+ readonly count: number;
73
+ readonly [key: string]: unknown;
74
+ }
75
+ declare function alertsPath(): string;
76
+ declare function readAlerts(): readonly Alert[];
77
+ declare function writeAlert(project: string, alertType: string, message: string): void;
78
+ declare function writeAlertWithThreshold(project: string, alertType: string, message: string): void;
79
+ declare function resetFailureCount(project: string, alertType: string): void;
80
+ declare function clearAlerts(): void;
81
+ declare function formatAlertsForDisplay(alerts: readonly Record<string, unknown>[]): string;
82
+
83
+ /**
84
+ * Logging setup for heartbeat — file output + stderr.
85
+ * Port of scripts/_log_utils.py
86
+ */
87
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error';
88
+ interface Logger {
89
+ debug(msg: string): void;
90
+ info(msg: string): void;
91
+ warn(msg: string): void;
92
+ error(msg: string): void;
93
+ exception(msg: string, err?: unknown): void;
94
+ }
95
+ /**
96
+ * Create (or return existing) singleton logger.
97
+ *
98
+ * The `logLevel` parameter is only used on the *first* call. Subsequent
99
+ * calls return the existing logger regardless of the level argument.
100
+ */
101
+ declare function setupLogging(logLevel?: LogLevel): Logger;
102
+ /**
103
+ * Return the singleton logger. If `setupLogging` has not been called yet
104
+ * the logger is auto-created with default settings (`info` level).
105
+ */
106
+ declare function getLogger(): Logger;
107
+ /**
108
+ * Reset the singleton — primarily useful for tests.
109
+ */
110
+ declare function _resetLogger(): void;
111
+
112
+ export { ALERT_TYPES, type Alert, FAILURE_THRESHOLD, type LogLevel, type Logger, MAX_ALERTS, _resetLogger, acquireLock, alertsPath, clearAlerts, configPath, formatAlertsForDisplay, getLogger, gitCommitAndPush, main as heartbeatMain, intervalToSeconds, isProcessAlive, loadConfig, lockPath, memorytreeRoot, processProject, readAlerts, readLockPid, registerProject, releaseLock, resetFailureCount, runHeartbeat, saveConfig, scanSensitive, setupLogging, tryPush, writeAlert, writeAlertWithThreshold };
package/dist/index.js ADDED
@@ -0,0 +1,691 @@
1
+ // src/config.ts
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { dirname, resolve } from "path";
5
+ import { parse as parseToml } from "smol-toml";
6
+ import { toPosixPath } from "@openmnemo/core";
7
+ var DEFAULT_HEARTBEAT_INTERVAL = "5m";
8
+ var DEFAULT_AUTO_PUSH = true;
9
+ var DEFAULT_LOG_LEVEL = "info";
10
+ var VALID_LOG_LEVELS = /* @__PURE__ */ new Set(["debug", "info", "warn", "error"]);
11
+ function memorytreeRoot() {
12
+ return resolve(homedir(), ".memorytree");
13
+ }
14
+ function configPath() {
15
+ return resolve(memorytreeRoot(), "config.toml");
16
+ }
17
+ function loadConfig() {
18
+ const path = configPath();
19
+ if (!existsSync(path)) {
20
+ return defaultConfig();
21
+ }
22
+ let raw;
23
+ try {
24
+ const text = readFileSync(path, "utf-8");
25
+ raw = parseToml(text);
26
+ } catch {
27
+ return defaultConfig();
28
+ }
29
+ return parseRaw(raw);
30
+ }
31
+ function saveConfig(cfg) {
32
+ const path = configPath();
33
+ mkdirSync(dirname(path), { recursive: true });
34
+ const lines = [
35
+ `heartbeat_interval = ${tomlString(cfg.heartbeat_interval)}`,
36
+ `auto_push = ${cfg.auto_push ? "true" : "false"}`,
37
+ `log_level = ${tomlString(cfg.log_level)}`
38
+ ];
39
+ if (cfg.watch_dirs.length > 0) {
40
+ const items = cfg.watch_dirs.map((d) => tomlString(d)).join(", ");
41
+ lines.push(`watch_dirs = [${items}]`);
42
+ } else {
43
+ lines.push("watch_dirs = []");
44
+ }
45
+ lines.push("");
46
+ for (const project of cfg.projects) {
47
+ lines.push("[[projects]]");
48
+ lines.push(`path = ${tomlString(project.path)}`);
49
+ if (project.name) {
50
+ lines.push(`name = ${tomlString(project.name)}`);
51
+ }
52
+ lines.push("");
53
+ }
54
+ writeFileSync(path, lines.join("\n") + "\n", "utf-8");
55
+ }
56
+ function intervalToSeconds(interval) {
57
+ const match = interval.trim().toLowerCase().match(/^(\d+)\s*(s|m|h)$/);
58
+ if (!match) {
59
+ return intervalToSeconds(DEFAULT_HEARTBEAT_INTERVAL);
60
+ }
61
+ const value = parseInt(match[1], 10);
62
+ const unit = match[2];
63
+ if (value <= 0) {
64
+ return intervalToSeconds(DEFAULT_HEARTBEAT_INTERVAL);
65
+ }
66
+ const multiplier = { s: 1, m: 60, h: 3600 };
67
+ return value * (multiplier[unit] ?? 60);
68
+ }
69
+ function registerProject(cfg, repoPath) {
70
+ const resolved = toPosixPath(resolve(repoPath));
71
+ for (const entry of cfg.projects) {
72
+ if (toPosixPath(resolve(entry.path)) === resolved) {
73
+ return cfg;
74
+ }
75
+ }
76
+ const name = resolved.split("/").pop() ?? "";
77
+ return {
78
+ ...cfg,
79
+ projects: [...cfg.projects, { path: resolved, name }]
80
+ };
81
+ }
82
+ function defaultConfig() {
83
+ return {
84
+ heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
85
+ watch_dirs: [],
86
+ projects: [],
87
+ auto_push: DEFAULT_AUTO_PUSH,
88
+ log_level: DEFAULT_LOG_LEVEL
89
+ };
90
+ }
91
+ function parseRaw(raw) {
92
+ let interval = raw["heartbeat_interval"];
93
+ if (typeof interval !== "string" || !isValidInterval(interval)) {
94
+ interval = DEFAULT_HEARTBEAT_INTERVAL;
95
+ }
96
+ let autoPush = raw["auto_push"];
97
+ if (typeof autoPush !== "boolean") {
98
+ autoPush = DEFAULT_AUTO_PUSH;
99
+ }
100
+ let logLevel = raw["log_level"];
101
+ if (typeof logLevel !== "string" || !VALID_LOG_LEVELS.has(logLevel.toLowerCase())) {
102
+ logLevel = DEFAULT_LOG_LEVEL;
103
+ }
104
+ logLevel = logLevel.toLowerCase();
105
+ const watchDirs = [];
106
+ const rawDirs = raw["watch_dirs"];
107
+ if (Array.isArray(rawDirs)) {
108
+ for (const d of rawDirs) {
109
+ if (typeof d === "string") watchDirs.push(d);
110
+ }
111
+ }
112
+ const projects = [];
113
+ const rawProjects = raw["projects"];
114
+ if (Array.isArray(rawProjects)) {
115
+ for (const entry of rawProjects) {
116
+ if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
117
+ const rec = entry;
118
+ if (typeof rec["path"] === "string") {
119
+ projects.push({
120
+ path: rec["path"],
121
+ name: String(rec["name"] ?? "")
122
+ });
123
+ }
124
+ }
125
+ }
126
+ }
127
+ return {
128
+ heartbeat_interval: interval,
129
+ watch_dirs: watchDirs,
130
+ projects,
131
+ auto_push: autoPush,
132
+ log_level: logLevel
133
+ };
134
+ }
135
+ function isValidInterval(value) {
136
+ const match = value.trim().toLowerCase().match(/^(\d+)\s*(s|m|h)$/);
137
+ if (!match) return false;
138
+ return parseInt(match[1], 10) > 0;
139
+ }
140
+ function tomlString(value) {
141
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
142
+ return `"${escaped}"`;
143
+ }
144
+
145
+ // src/heartbeat.ts
146
+ import { discoverSourceFiles, transcriptMatchesRepo } from "@openmnemo/core";
147
+ import { importTranscript, transcriptHasContent } from "@openmnemo/core";
148
+ import { parseTranscript } from "@openmnemo/core";
149
+ import { slugify } from "@openmnemo/core";
150
+ import { defaultGlobalTranscriptRoot } from "@openmnemo/core";
151
+
152
+ // src/lock.ts
153
+ import {
154
+ closeSync,
155
+ constants,
156
+ existsSync as existsSync2,
157
+ mkdirSync as mkdirSync2,
158
+ openSync,
159
+ readFileSync as readFileSync2,
160
+ unlinkSync,
161
+ writeSync
162
+ } from "fs";
163
+ import { homedir as homedir2 } from "os";
164
+ import { dirname as dirname2, resolve as resolve2 } from "path";
165
+ import { execFileSync } from "child_process";
166
+ function lockPath() {
167
+ return resolve2(homedir2(), ".memorytree", "heartbeat.lock");
168
+ }
169
+ function acquireLock() {
170
+ const path = lockPath();
171
+ mkdirSync2(dirname2(path), { recursive: true });
172
+ if (existsSync2(path)) {
173
+ let storedPid;
174
+ try {
175
+ const raw = readFileSync2(path, "utf-8").trim();
176
+ storedPid = parseInt(raw, 10);
177
+ if (!Number.isFinite(storedPid)) {
178
+ storedPid = void 0;
179
+ }
180
+ } catch {
181
+ storedPid = void 0;
182
+ }
183
+ if (storedPid === void 0) {
184
+ removeLockFile(path);
185
+ } else {
186
+ if (isProcessAlive(storedPid)) {
187
+ return false;
188
+ }
189
+ removeLockFile(path);
190
+ }
191
+ }
192
+ try {
193
+ const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
194
+ const pidBytes = Buffer.from(String(process.pid), "utf-8");
195
+ writeSync(fd, pidBytes);
196
+ closeSync(fd);
197
+ } catch {
198
+ return false;
199
+ }
200
+ return true;
201
+ }
202
+ function releaseLock() {
203
+ removeLockFile(lockPath());
204
+ }
205
+ function readLockPid() {
206
+ const path = lockPath();
207
+ if (!existsSync2(path)) {
208
+ return null;
209
+ }
210
+ try {
211
+ const raw = readFileSync2(path, "utf-8").trim();
212
+ const pid = parseInt(raw, 10);
213
+ return Number.isFinite(pid) ? pid : null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+ function isProcessAlive(pid) {
219
+ if (pid <= 0) {
220
+ return false;
221
+ }
222
+ if (process.platform === "win32") {
223
+ return isProcessAliveWindows(pid);
224
+ }
225
+ return isProcessAliveUnix(pid);
226
+ }
227
+ function isProcessAliveUnix(pid) {
228
+ try {
229
+ process.kill(pid, 0);
230
+ return true;
231
+ } catch (err) {
232
+ if (err instanceof Error && "code" in err) {
233
+ if (err.code === "EPERM") {
234
+ return true;
235
+ }
236
+ }
237
+ return false;
238
+ }
239
+ }
240
+ function isProcessAliveWindows(pid) {
241
+ try {
242
+ const output = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/NH"], {
243
+ encoding: "utf-8",
244
+ timeout: 5e3,
245
+ windowsHide: true
246
+ });
247
+ return new RegExp(`\\b${pid}\\b`).test(output);
248
+ } catch {
249
+ return false;
250
+ }
251
+ }
252
+ function removeLockFile(path) {
253
+ try {
254
+ unlinkSync(path);
255
+ } catch {
256
+ }
257
+ }
258
+
259
+ // src/alert.ts
260
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
261
+ import { homedir as homedir3 } from "os";
262
+ import { dirname as dirname3, resolve as resolve3 } from "path";
263
+ var MAX_ALERTS = 100;
264
+ var ALERT_TYPES = /* @__PURE__ */ new Set([
265
+ "no_remote",
266
+ "sensitive_match",
267
+ "push_failed",
268
+ "lock_held"
269
+ ]);
270
+ var FAILURE_THRESHOLD = 3;
271
+ function alertsPath() {
272
+ return resolve3(homedir3(), ".memorytree", "alerts.json");
273
+ }
274
+ function failureStatePath() {
275
+ return resolve3(homedir3(), ".memorytree", "failure_counts.json");
276
+ }
277
+ function utcTimestamp() {
278
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
279
+ }
280
+ function readAlerts() {
281
+ const path = alertsPath();
282
+ if (!existsSync3(path)) {
283
+ return [];
284
+ }
285
+ try {
286
+ const text = readFileSync3(path, "utf-8");
287
+ const data = JSON.parse(text);
288
+ if (!Array.isArray(data)) {
289
+ return [];
290
+ }
291
+ return data;
292
+ } catch {
293
+ return [];
294
+ }
295
+ }
296
+ function writeAlert(project, alertType, message) {
297
+ const alerts = readAlerts();
298
+ const now = utcTimestamp();
299
+ let found = false;
300
+ const updated = [];
301
+ for (const alert of alerts) {
302
+ if (alert.project === project && alert.type === alertType) {
303
+ updated.push({
304
+ ...alert,
305
+ timestamp: now,
306
+ message,
307
+ count: (alert.count ?? 1) + 1
308
+ });
309
+ found = true;
310
+ } else {
311
+ updated.push({ ...alert });
312
+ }
313
+ }
314
+ if (!found) {
315
+ updated.push({ timestamp: now, project, type: alertType, message, count: 1 });
316
+ }
317
+ const trimmed = updated.length > MAX_ALERTS ? updated.slice(updated.length - MAX_ALERTS) : updated;
318
+ saveAlerts(trimmed);
319
+ }
320
+ function writeAlertWithThreshold(project, alertType, message) {
321
+ const counts = readFailureCounts();
322
+ const key = `${project}::${alertType}`;
323
+ const current = (counts[key] ?? 0) + 1;
324
+ const newCounts = { ...counts, [key]: current };
325
+ saveFailureCounts(newCounts);
326
+ if (current >= FAILURE_THRESHOLD) {
327
+ writeAlert(project, alertType, message);
328
+ }
329
+ }
330
+ function resetFailureCount(project, alertType) {
331
+ const counts = readFailureCounts();
332
+ const key = `${project}::${alertType}`;
333
+ if (key in counts) {
334
+ const newCounts = {};
335
+ for (const [k, v] of Object.entries(counts)) {
336
+ if (k !== key) {
337
+ newCounts[k] = v;
338
+ }
339
+ }
340
+ saveFailureCounts(newCounts);
341
+ }
342
+ }
343
+ function clearAlerts() {
344
+ const path = alertsPath();
345
+ if (existsSync3(path)) {
346
+ try {
347
+ unlinkSync2(path);
348
+ } catch {
349
+ }
350
+ }
351
+ }
352
+ function formatAlertsForDisplay(alerts) {
353
+ if (alerts.length === 0) {
354
+ return "";
355
+ }
356
+ const lines = [];
357
+ for (const alert of alerts) {
358
+ const count = typeof alert["count"] === "number" ? alert["count"] : 1;
359
+ const countSuffix = count > 1 ? ` (x${String(count)})` : "";
360
+ const type = typeof alert["type"] === "string" ? alert["type"] : "unknown";
361
+ const project = typeof alert["project"] === "string" ? alert["project"] : "?";
362
+ const message = typeof alert["message"] === "string" ? alert["message"] : "";
363
+ lines.push(` [${type}] ${project}: ${message}${countSuffix}`);
364
+ }
365
+ return lines.join("\n");
366
+ }
367
+ function saveAlerts(alerts) {
368
+ const path = alertsPath();
369
+ mkdirSync3(dirname3(path), { recursive: true });
370
+ writeFileSync2(path, JSON.stringify(alerts, null, 2) + "\n", "utf-8");
371
+ }
372
+ function readFailureCounts() {
373
+ const path = failureStatePath();
374
+ if (!existsSync3(path)) {
375
+ return {};
376
+ }
377
+ try {
378
+ const text = readFileSync3(path, "utf-8");
379
+ const data = JSON.parse(text);
380
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
381
+ return {};
382
+ }
383
+ return data;
384
+ } catch {
385
+ return {};
386
+ }
387
+ }
388
+ function saveFailureCounts(counts) {
389
+ const path = failureStatePath();
390
+ mkdirSync3(dirname3(path), { recursive: true });
391
+ writeFileSync2(path, JSON.stringify(counts, null, 2) + "\n", "utf-8");
392
+ }
393
+
394
+ // src/log.ts
395
+ import { appendFileSync, mkdirSync as mkdirSync4 } from "fs";
396
+ import { homedir as homedir4 } from "os";
397
+ import { resolve as resolve4 } from "path";
398
+ var LEVEL_PRIORITY = {
399
+ debug: 0,
400
+ info: 1,
401
+ warn: 2,
402
+ error: 3
403
+ };
404
+ var LEVEL_LABELS = {
405
+ debug: "DEBUG",
406
+ info: "INFO",
407
+ warn: "WARN",
408
+ error: "ERROR"
409
+ };
410
+ var singleton;
411
+ var MemoryTreeLogger = class {
412
+ levelPriority;
413
+ logFilePath;
414
+ constructor(logLevel) {
415
+ this.levelPriority = LEVEL_PRIORITY[logLevel];
416
+ const filePath = logFilePathForToday();
417
+ const logDir = resolve4(filePath, "..");
418
+ try {
419
+ mkdirSync4(logDir, { recursive: true });
420
+ this.logFilePath = filePath;
421
+ } catch {
422
+ this.writeStderr("WARN", `Could not open log file: ${filePath}`);
423
+ this.logFilePath = void 0;
424
+ }
425
+ }
426
+ debug(msg) {
427
+ this.log("debug", msg);
428
+ }
429
+ info(msg) {
430
+ this.log("info", msg);
431
+ }
432
+ warn(msg) {
433
+ this.log("warn", msg);
434
+ }
435
+ error(msg) {
436
+ this.log("error", msg);
437
+ }
438
+ exception(msg, err) {
439
+ if (err instanceof Error && err.stack) {
440
+ this.log("error", `${msg}
441
+ ${err.stack}`);
442
+ } else if (err !== void 0) {
443
+ this.log("error", `${msg}
444
+ ${String(err)}`);
445
+ } else {
446
+ this.log("error", msg);
447
+ }
448
+ }
449
+ // -----------------------------------------------------------------------
450
+ // Internal
451
+ // -----------------------------------------------------------------------
452
+ log(level, msg) {
453
+ if (LEVEL_PRIORITY[level] < this.levelPriority) {
454
+ return;
455
+ }
456
+ const label = LEVEL_LABELS[level];
457
+ const formatted = `${timestamp()} [${label}] ${msg}`;
458
+ this.writeStderr(label, msg);
459
+ this.writeFile(formatted);
460
+ }
461
+ writeStderr(_label, _msg) {
462
+ const line = `${timestamp()} [${_label}] ${_msg}
463
+ `;
464
+ process.stderr.write(line);
465
+ }
466
+ writeFile(formatted) {
467
+ if (this.logFilePath === void 0) {
468
+ return;
469
+ }
470
+ try {
471
+ appendFileSync(this.logFilePath, formatted + "\n", "utf-8");
472
+ } catch {
473
+ }
474
+ }
475
+ };
476
+ function setupLogging(logLevel = "info") {
477
+ if (singleton !== void 0) {
478
+ return singleton;
479
+ }
480
+ singleton = new MemoryTreeLogger(resolveLevel(logLevel));
481
+ return singleton;
482
+ }
483
+ function getLogger() {
484
+ if (singleton === void 0) {
485
+ singleton = new MemoryTreeLogger(resolveLevel("info"));
486
+ }
487
+ return singleton;
488
+ }
489
+ function _resetLogger() {
490
+ singleton = void 0;
491
+ }
492
+ function resolveLevel(level) {
493
+ const lower = level.toLowerCase();
494
+ const mapping = {
495
+ debug: "debug",
496
+ info: "info",
497
+ warn: "warn",
498
+ warning: "warn",
499
+ error: "error"
500
+ };
501
+ return mapping[lower] ?? "info";
502
+ }
503
+ function logFilePathForToday() {
504
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
505
+ return resolve4(homedir4(), ".memorytree", "logs", `heartbeat-${today}.log`);
506
+ }
507
+ function timestamp() {
508
+ const now = /* @__PURE__ */ new Date();
509
+ const y = now.getUTCFullYear();
510
+ const mo = String(now.getUTCMonth() + 1).padStart(2, "0");
511
+ const d = String(now.getUTCDate()).padStart(2, "0");
512
+ const h = String(now.getUTCHours()).padStart(2, "0");
513
+ const mi = String(now.getUTCMinutes()).padStart(2, "0");
514
+ const s = String(now.getUTCSeconds()).padStart(2, "0");
515
+ return `${y}-${mo}-${d}T${h}:${mi}:${s}`;
516
+ }
517
+
518
+ // src/heartbeat.ts
519
+ import { git } from "@openmnemo/core";
520
+ import { toPosixPath as toPosixPath2 } from "@openmnemo/core";
521
+ import { resolve as resolve5 } from "path";
522
+ import { existsSync as existsSync4 } from "fs";
523
+ var SENSITIVE_PATTERNS = [
524
+ /(?:api[_-]?key|apikey)\s*[:=]\s*\S+/i,
525
+ /(?:password|passwd|pwd)\s*[:=]\s*\S+/i,
526
+ /(?:secret|token)\s*[:=]\s*\S+/i,
527
+ /(?:sk-|pk_live_|sk_live_|ghp_|gho_|glpat-)\S{10,}/,
528
+ /Bearer\s+\S{20,}/i
529
+ ];
530
+ async function main() {
531
+ const config = loadConfig();
532
+ setupLogging(config.log_level);
533
+ const logger = getLogger();
534
+ if (!acquireLock()) {
535
+ logger.info("Another heartbeat instance is running. Exiting.");
536
+ writeAlert("global", "lock_held", "Heartbeat exited: another instance held the lock.");
537
+ return 0;
538
+ }
539
+ try {
540
+ return await runHeartbeat(config);
541
+ } finally {
542
+ releaseLock();
543
+ }
544
+ }
545
+ async function runHeartbeat(config) {
546
+ const logger = getLogger();
547
+ if (config.projects.length === 0) {
548
+ logger.info("No projects registered in config.toml. Nothing to do.");
549
+ return 0;
550
+ }
551
+ logger.info(`Heartbeat started. ${config.projects.length} project(s) registered.`);
552
+ for (const entry of config.projects) {
553
+ const projectPath = resolve5(entry.path);
554
+ if (!existsSync4(projectPath)) {
555
+ logger.warn(`Project path does not exist, skipping: ${projectPath}`);
556
+ continue;
557
+ }
558
+ try {
559
+ await processProject(config, projectPath, entry.name || (projectPath.split(/[/\\]/).pop() ?? ""));
560
+ } catch (err) {
561
+ logger.exception(`Error processing project: ${projectPath}`, err);
562
+ writeAlertWithThreshold(
563
+ toPosixPath2(projectPath),
564
+ "push_failed",
565
+ `Heartbeat error for project: ${projectPath.split(/[/\\]/).pop() ?? ""}`
566
+ );
567
+ }
568
+ }
569
+ logger.info("Heartbeat finished.");
570
+ return 0;
571
+ }
572
+ async function processProject(config, projectPath, projectName) {
573
+ const logger = getLogger();
574
+ const repoSlug = slugify(projectName, "project");
575
+ const globalRoot = defaultGlobalTranscriptRoot();
576
+ const discovered = discoverSourceFiles();
577
+ let importedCount = 0;
578
+ for (const [client, source] of discovered) {
579
+ let parsed;
580
+ try {
581
+ parsed = parseTranscript(client, source);
582
+ } catch {
583
+ logger.debug(`Failed to parse ${source}, skipping.`);
584
+ continue;
585
+ }
586
+ if (!transcriptHasContent(parsed)) continue;
587
+ if (!transcriptMatchesRepo(parsed, projectPath, repoSlug)) continue;
588
+ scanSensitive(parsed, projectPath);
589
+ try {
590
+ await importTranscript(parsed, projectPath, globalRoot, repoSlug, "not-set", true);
591
+ importedCount++;
592
+ } catch {
593
+ logger.exception(`Failed to import transcript: ${source}`);
594
+ }
595
+ }
596
+ if (importedCount === 0) {
597
+ logger.info(`[${projectName}] No new transcripts to import.`);
598
+ return;
599
+ }
600
+ logger.info(`[${projectName}] Imported ${importedCount} transcript(s).`);
601
+ gitCommitAndPush(config, projectPath, projectName, importedCount);
602
+ }
603
+ function scanSensitive(parsed, projectPath) {
604
+ const logger = getLogger();
605
+ for (const msg of parsed.messages) {
606
+ for (const pattern of SENSITIVE_PATTERNS) {
607
+ if (pattern.test(msg.text)) {
608
+ logger.warn(
609
+ `Sensitive pattern detected in transcript ${parsed.source_path} (project: ${projectPath.split(/[/\\]/).pop() ?? ""}, role: ${msg.role})`
610
+ );
611
+ writeAlert(
612
+ toPosixPath2(projectPath),
613
+ "sensitive_match",
614
+ `Sensitive pattern in transcript: ${parsed.source_path.split(/[/\\]/).pop() ?? ""}`
615
+ );
616
+ return;
617
+ }
618
+ }
619
+ }
620
+ }
621
+ function gitCommitAndPush(config, projectPath, projectName, count) {
622
+ const logger = getLogger();
623
+ const status = git(projectPath, "status", "--porcelain", "Memory/");
624
+ if (!status.trim()) {
625
+ logger.info(`[${projectName}] No git changes in Memory/.`);
626
+ return;
627
+ }
628
+ git(projectPath, "add", "Memory/");
629
+ git(projectPath, "commit", "-m", `memorytree(transcripts): import ${count} transcript(s)`);
630
+ logger.info(`[${projectName}] Committed ${count} transcript import(s).`);
631
+ if (!config.auto_push) {
632
+ logger.info(`[${projectName}] auto_push disabled, skipping push.`);
633
+ return;
634
+ }
635
+ const remotes = git(projectPath, "remote");
636
+ if (!remotes.trim()) {
637
+ logger.warn(`[${projectName}] No git remote configured, skipping push.`);
638
+ writeAlert(toPosixPath2(projectPath), "no_remote", "Push skipped: no Git remote configured.");
639
+ return;
640
+ }
641
+ if (!tryPush(projectPath, projectName)) {
642
+ logger.warn(`[${projectName}] Push failed, retrying once...`);
643
+ if (!tryPush(projectPath, projectName)) {
644
+ logger.error(`[${projectName}] Push failed after retry.`);
645
+ writeAlertWithThreshold(toPosixPath2(projectPath), "push_failed", "Push failed after retry.");
646
+ return;
647
+ }
648
+ }
649
+ resetFailureCount(toPosixPath2(projectPath), "push_failed");
650
+ }
651
+ function tryPush(projectPath, projectName) {
652
+ try {
653
+ git(projectPath, "push");
654
+ getLogger().info(`[${projectName}] Pushed successfully.`);
655
+ return true;
656
+ } catch {
657
+ return false;
658
+ }
659
+ }
660
+ export {
661
+ ALERT_TYPES,
662
+ FAILURE_THRESHOLD,
663
+ MAX_ALERTS,
664
+ _resetLogger,
665
+ acquireLock,
666
+ alertsPath,
667
+ clearAlerts,
668
+ configPath,
669
+ formatAlertsForDisplay,
670
+ getLogger,
671
+ gitCommitAndPush,
672
+ main as heartbeatMain,
673
+ intervalToSeconds,
674
+ isProcessAlive,
675
+ loadConfig,
676
+ lockPath,
677
+ memorytreeRoot,
678
+ processProject,
679
+ readAlerts,
680
+ readLockPid,
681
+ registerProject,
682
+ releaseLock,
683
+ resetFailureCount,
684
+ runHeartbeat,
685
+ saveConfig,
686
+ scanSensitive,
687
+ setupLogging,
688
+ tryPush,
689
+ writeAlert,
690
+ writeAlertWithThreshold
691
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@openmnemo/sync",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Heartbeat daemon, config management, and background sync for OpenMnemo",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/openmnemo/openmnemo.git",
11
+ "directory": "packages/sync"
12
+ },
13
+ "homepage": "https://github.com/openmnemo/openmnemo#readme",
14
+ "author": "OpenMnemo Contributors",
15
+ "license": "MIT",
16
+ "keywords": [
17
+ "openmnemo",
18
+ "memory",
19
+ "sync",
20
+ "heartbeat",
21
+ "daemon"
22
+ ],
23
+ "dependencies": {
24
+ "smol-toml": "^1.3.1",
25
+ "@openmnemo/core": "0.1.0",
26
+ "@openmnemo/types": "0.1.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^25.5.0",
30
+ "tsup": "^8.4.0",
31
+ "typescript": "^5.7.3",
32
+ "vitest": "^3.1.1"
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsup src/index.ts --format esm --dts",
39
+ "test": "vitest run",
40
+ "lint": "eslint src tests",
41
+ "typecheck": "tsc --noEmit"
42
+ }
43
+ }