@nessielabs/daemon 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/README.md +50 -0
- package/dist/api/http.js +25 -0
- package/dist/auth/deviceFlow.js +35 -0
- package/dist/auth/tokens.js +29 -0
- package/dist/cli.js +29 -0
- package/dist/commands/run.js +10 -0
- package/dist/commands/service.js +6 -0
- package/dist/commands/setup.js +43 -0
- package/dist/config/paths.js +17 -0
- package/dist/config/store.js +59 -0
- package/dist/parser/claudeCode.js +75 -0
- package/dist/parser/codex.js +82 -0
- package/dist/parser/jsonl.js +24 -0
- package/dist/parser/types.js +1 -0
- package/dist/redact/secrets.js +38 -0
- package/dist/service/systemd.js +38 -0
- package/dist/sources/detect.js +43 -0
- package/dist/sync/client.js +37 -0
- package/dist/sync/ids.js +11 -0
- package/dist/sync/loop.js +48 -0
- package/dist/sync/map.js +88 -0
- package/dist/sync/scan.js +58 -0
- package/dist/sync/types.js +1 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Nessie Daemon
|
|
2
|
+
|
|
3
|
+
Headless Linux ingestion daemon for Nessie agent traces.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @nessielabs/daemon
|
|
9
|
+
nessie-daemon setup
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`setup` authenticates the device, detects local Codex and Claude Code history directories, lets you choose which sources to ingest, and can install the user `systemd` service.
|
|
13
|
+
|
|
14
|
+
Use non-production endpoints with:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
nessie-daemon setup --api-url https://sync.example --auth-api-url https://auth.example
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
nessie-daemon setup
|
|
24
|
+
nessie-daemon run --once
|
|
25
|
+
nessie-daemon start
|
|
26
|
+
nessie-daemon stop
|
|
27
|
+
nessie-daemon status
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The daemon registers selected local resources through sync preflight and pushes local agent traces. It does not pull cloud data.
|
|
31
|
+
|
|
32
|
+
Config is stored at `~/.config/nessie-daemon/config.json`; sync state is stored at `~/.local/state/nessie-daemon/state.json`.
|
|
33
|
+
|
|
34
|
+
Re-running `setup` rewrites the source selection and resets sync cursors, which can trigger a full re-scan and idempotent re-push of local history.
|
|
35
|
+
|
|
36
|
+
The first implementation uses file `mtime` and `size` cursors. When a JSONL file changes, the daemon reparses that file and relies on stable node IDs for idempotent pushes.
|
|
37
|
+
|
|
38
|
+
## systemd
|
|
39
|
+
|
|
40
|
+
`setup` can install a user service at:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
~/.config/systemd/user/nessie-daemon.service
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
On long-running headless VMs, enable user services to survive logout:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
sudo loginctl enable-linger "$USER"
|
|
50
|
+
```
|
package/dist/api/http.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
body;
|
|
4
|
+
constructor(message, status, body) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.body = body;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export async function postJson(baseUrl, path, body, options = {}) {
|
|
11
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
12
|
+
const response = await fetchImpl(new URL(path, baseUrl), {
|
|
13
|
+
method: "POST",
|
|
14
|
+
headers: {
|
|
15
|
+
"content-type": "application/json",
|
|
16
|
+
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
|
|
17
|
+
},
|
|
18
|
+
body: JSON.stringify(body),
|
|
19
|
+
});
|
|
20
|
+
const text = await response.text();
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
throw new ApiError(`HTTP status ${response.status}: ${text || response.statusText}`, response.status, text);
|
|
23
|
+
}
|
|
24
|
+
return (text ? JSON.parse(text) : {});
|
|
25
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { postJson } from "../api/http.js";
|
|
3
|
+
export async function runDeviceAuth(input) {
|
|
4
|
+
const log = input.log ?? console;
|
|
5
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
6
|
+
const sleepMs = input.sleepMs ?? sleep;
|
|
7
|
+
const start = await postJson(input.authApiUrl, "/auth/device/start", {
|
|
8
|
+
device_name: input.deviceName,
|
|
9
|
+
platform: "linux",
|
|
10
|
+
}, { fetchImpl });
|
|
11
|
+
log.log(`Open ${start.verification_uri_complete ?? start.verification_uri ?? "Nessie"} and enter code ${start.user_code}.`);
|
|
12
|
+
const intervalMs = Math.max(1, start.interval ?? 5) * 1000;
|
|
13
|
+
const expiresAt = Date.now() + Math.max(60, start.expires_in ?? 600) * 1000;
|
|
14
|
+
while (Date.now() < expiresAt) {
|
|
15
|
+
await sleepMs(intervalMs);
|
|
16
|
+
const poll = await postJson(input.authApiUrl, "/auth/device/poll", {
|
|
17
|
+
device_code: start.device_code,
|
|
18
|
+
}, { fetchImpl });
|
|
19
|
+
if (poll.status === "pending")
|
|
20
|
+
continue;
|
|
21
|
+
if (poll.status === "approved" && poll.access_token && poll.device_id) {
|
|
22
|
+
return {
|
|
23
|
+
accessToken: poll.access_token,
|
|
24
|
+
refreshToken: poll.refresh_token ?? null,
|
|
25
|
+
deviceId: poll.device_id,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (poll.status === "approved") {
|
|
29
|
+
throw new Error("Auth approved but server response missing access_token or device_id.");
|
|
30
|
+
}
|
|
31
|
+
if (poll.status === "expired")
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
throw new Error("Device auth expired before approval.");
|
|
35
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ApiError, postJson } from "../api/http.js";
|
|
2
|
+
export class AuthRefreshError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export async function refreshAccessToken(config, options = {}) {
|
|
5
|
+
if (!config.refreshToken) {
|
|
6
|
+
throw new AuthRefreshError("Access token expired and no refresh token is configured.");
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const response = await postJson(config.authApiUrl, "/auth/refresh", {
|
|
10
|
+
refresh_token: config.refreshToken,
|
|
11
|
+
}, { fetchImpl: options.fetchImpl });
|
|
12
|
+
if (!response.access_token) {
|
|
13
|
+
throw new AuthRefreshError("Token refresh response missing access_token.");
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
...config,
|
|
17
|
+
accessToken: response.access_token,
|
|
18
|
+
refreshToken: response.refresh_token ?? config.refreshToken,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error instanceof AuthRefreshError)
|
|
23
|
+
throw error;
|
|
24
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
25
|
+
throw new AuthRefreshError("Refresh token is invalid or expired.");
|
|
26
|
+
}
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { runDaemon } from "./commands/run.js";
|
|
4
|
+
import { runServiceCommand } from "./commands/service.js";
|
|
5
|
+
import { runSetup } from "./commands/setup.js";
|
|
6
|
+
const program = new Command();
|
|
7
|
+
program
|
|
8
|
+
.name("nessie-daemon")
|
|
9
|
+
.description("Headless Nessie ingestion daemon for Linux agent machines.")
|
|
10
|
+
.version("0.1.0");
|
|
11
|
+
program
|
|
12
|
+
.command("setup")
|
|
13
|
+
.description("Authenticate, select local sources, and optionally install the user systemd service.")
|
|
14
|
+
.option("--api-url <url>", "Nessie sync API URL")
|
|
15
|
+
.option("--auth-api-url <url>", "Nessie auth API URL")
|
|
16
|
+
.action(runSetup);
|
|
17
|
+
program
|
|
18
|
+
.command("run")
|
|
19
|
+
.description("Run the daemon sync loop.")
|
|
20
|
+
.option("--once", "Run one sync pass and exit.")
|
|
21
|
+
.option("--interval-seconds <seconds>", "Seconds between sync passes.", "60")
|
|
22
|
+
.action(runDaemon);
|
|
23
|
+
for (const commandName of ["start", "stop", "status"]) {
|
|
24
|
+
program
|
|
25
|
+
.command(commandName)
|
|
26
|
+
.description(`${commandName[0]?.toUpperCase()}${commandName.slice(1)} the user systemd service.`)
|
|
27
|
+
.action(() => runServiceCommand(commandName));
|
|
28
|
+
}
|
|
29
|
+
await program.parseAsync();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { runSyncLoop, runSyncOnce } from "../sync/loop.js";
|
|
2
|
+
export async function runDaemon(options) {
|
|
3
|
+
if (options.once) {
|
|
4
|
+
const result = await runSyncOnce();
|
|
5
|
+
console.log(`Synced ${result.nodes} nodes and ${result.edges} edges.`);
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
const intervalSeconds = Number.parseInt(options.intervalSeconds ?? "60", 10);
|
|
9
|
+
await runSyncLoop(Number.isFinite(intervalSeconds) ? intervalSeconds : 60);
|
|
10
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { checkbox, confirm } from "@inquirer/prompts";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { runDeviceAuth } from "../auth/deviceFlow.js";
|
|
4
|
+
import { getDaemonPaths } from "../config/paths.js";
|
|
5
|
+
import { createConfig, createInitialState, defaultApiUrl, defaultAuthApiUrl, writeConfig, writeState } from "../config/store.js";
|
|
6
|
+
import { installUserService } from "../service/systemd.js";
|
|
7
|
+
import { detectSources } from "../sources/detect.js";
|
|
8
|
+
export async function runSetup(options) {
|
|
9
|
+
const apiUrl = options.apiUrl ?? defaultApiUrl;
|
|
10
|
+
const authApiUrl = options.authApiUrl ?? defaultAuthApiUrl;
|
|
11
|
+
const deviceName = hostname();
|
|
12
|
+
const auth = await runDeviceAuth({ authApiUrl, deviceName });
|
|
13
|
+
const detectedSources = await detectSources();
|
|
14
|
+
if (detectedSources.length === 0) {
|
|
15
|
+
throw new Error("No supported Codex or Claude Code history directories were found.");
|
|
16
|
+
}
|
|
17
|
+
const selectedSources = await checkbox({
|
|
18
|
+
message: "Select sources for this daemon to ingest",
|
|
19
|
+
choices: detectedSources.map((source) => ({
|
|
20
|
+
name: `${source.displayName} (${source.basePath})`,
|
|
21
|
+
value: source,
|
|
22
|
+
checked: true,
|
|
23
|
+
})),
|
|
24
|
+
required: true,
|
|
25
|
+
});
|
|
26
|
+
const paths = getDaemonPaths();
|
|
27
|
+
const config = createConfig({
|
|
28
|
+
accessToken: auth.accessToken,
|
|
29
|
+
refreshToken: auth.refreshToken,
|
|
30
|
+
deviceId: auth.deviceId,
|
|
31
|
+
deviceName,
|
|
32
|
+
apiUrl,
|
|
33
|
+
authApiUrl,
|
|
34
|
+
selectedSources,
|
|
35
|
+
});
|
|
36
|
+
await writeConfig(config, paths);
|
|
37
|
+
await writeState(createInitialState(), paths);
|
|
38
|
+
console.log(`Saved config to ${paths.configFile}`);
|
|
39
|
+
if (await confirm({ message: "Install and start the user systemd service?", default: true })) {
|
|
40
|
+
await installUserService(paths);
|
|
41
|
+
console.log("Installed and started nessie-daemon.service");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export function getDaemonPaths(env = process.env, home = homedir()) {
|
|
4
|
+
const configHome = env.XDG_CONFIG_HOME ?? join(home, ".config");
|
|
5
|
+
const stateHome = env.XDG_STATE_HOME ?? join(home, ".local", "state");
|
|
6
|
+
const configDir = join(configHome, "nessie-daemon");
|
|
7
|
+
const stateDir = join(stateHome, "nessie-daemon");
|
|
8
|
+
const systemdUserDir = join(configHome, "systemd", "user");
|
|
9
|
+
return {
|
|
10
|
+
configDir,
|
|
11
|
+
stateDir,
|
|
12
|
+
configFile: join(configDir, "config.json"),
|
|
13
|
+
stateFile: join(stateDir, "state.json"),
|
|
14
|
+
systemdUserDir,
|
|
15
|
+
systemdServiceFile: join(systemdUserDir, "nessie-daemon.service"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { getDaemonPaths } from "./paths.js";
|
|
6
|
+
export const defaultApiUrl = "https://nessie-codebase-843813578359.us-west1.run.app";
|
|
7
|
+
export const defaultAuthApiUrl = "https://nessie-notes-go-843813578359.us-west1.run.app";
|
|
8
|
+
export function createConfig(input) {
|
|
9
|
+
const deviceId = input.deviceId ?? randomUUID();
|
|
10
|
+
return {
|
|
11
|
+
apiUrl: input.apiUrl ?? defaultApiUrl,
|
|
12
|
+
authApiUrl: input.authApiUrl ?? defaultAuthApiUrl,
|
|
13
|
+
deviceId,
|
|
14
|
+
deviceName: input.deviceName ?? hostname(),
|
|
15
|
+
accessToken: input.accessToken,
|
|
16
|
+
refreshToken: input.refreshToken ?? null,
|
|
17
|
+
selectedSources: input.selectedSources.map((source) => ({
|
|
18
|
+
...source,
|
|
19
|
+
localId: randomUUID(),
|
|
20
|
+
stableKey: `device:${deviceId}`,
|
|
21
|
+
})),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function createInitialState() {
|
|
25
|
+
return {
|
|
26
|
+
files: {},
|
|
27
|
+
pushedSourceRootIds: [],
|
|
28
|
+
lastSyncAt: null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export async function readConfig(paths = getDaemonPaths()) {
|
|
32
|
+
return JSON.parse(await readFile(paths.configFile, "utf8"));
|
|
33
|
+
}
|
|
34
|
+
export async function writeConfig(config, paths = getDaemonPaths()) {
|
|
35
|
+
await mkdir(dirname(paths.configFile), { recursive: true });
|
|
36
|
+
await writeFile(paths.configFile, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
37
|
+
}
|
|
38
|
+
export async function readState(paths = getDaemonPaths()) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(await readFile(paths.stateFile, "utf8"));
|
|
41
|
+
return {
|
|
42
|
+
files: parsed.files ?? {},
|
|
43
|
+
pushedSourceRootIds: parsed.pushedSourceRootIds ?? [],
|
|
44
|
+
lastSyncAt: parsed.lastSyncAt ?? null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (isMissingFileError(error))
|
|
49
|
+
return createInitialState();
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function writeState(state, paths = getDaemonPaths()) {
|
|
54
|
+
await mkdir(dirname(paths.stateFile), { recursive: true });
|
|
55
|
+
await writeFile(paths.stateFile, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
56
|
+
}
|
|
57
|
+
function isMissingFileError(error) {
|
|
58
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
59
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { redactSecrets } from "../redact/secrets.js";
|
|
3
|
+
import { asRecord, readJsonl, stringValue } from "./jsonl.js";
|
|
4
|
+
export async function parseClaudeCodeSession(path) {
|
|
5
|
+
const records = await readJsonl(path);
|
|
6
|
+
const messages = [];
|
|
7
|
+
let sessionId = basename(path, ".jsonl");
|
|
8
|
+
let title = null;
|
|
9
|
+
let createdAt = null;
|
|
10
|
+
let updatedAt = null;
|
|
11
|
+
for (const value of records) {
|
|
12
|
+
const record = asRecord(value);
|
|
13
|
+
if (!record)
|
|
14
|
+
continue;
|
|
15
|
+
if (record.isSidechain === true)
|
|
16
|
+
continue;
|
|
17
|
+
sessionId = stringValue(record.sessionId) ?? stringValue(record.session_id) ?? sessionId;
|
|
18
|
+
const timestamp = stringValue(record.timestamp);
|
|
19
|
+
if (timestamp) {
|
|
20
|
+
createdAt ??= timestamp;
|
|
21
|
+
updatedAt = timestamp;
|
|
22
|
+
}
|
|
23
|
+
const role = normalizeRole(stringValue(record.type) ?? stringValue(record.role));
|
|
24
|
+
if (!role)
|
|
25
|
+
continue;
|
|
26
|
+
const text = extractClaudeText(record);
|
|
27
|
+
if (!text || shouldDropScaffolding(text))
|
|
28
|
+
continue;
|
|
29
|
+
messages.push({ role, content: text, timestamp });
|
|
30
|
+
if (!title && role === "user")
|
|
31
|
+
title = redactSecrets(firstLine(text));
|
|
32
|
+
}
|
|
33
|
+
if (messages.length === 0)
|
|
34
|
+
return null;
|
|
35
|
+
const now = new Date().toISOString();
|
|
36
|
+
return {
|
|
37
|
+
id: sessionId,
|
|
38
|
+
sourceKind: "claude_code",
|
|
39
|
+
sourceId: path,
|
|
40
|
+
title: title ?? "Claude Code conversation",
|
|
41
|
+
createdAt: createdAt ?? now,
|
|
42
|
+
updatedAt: updatedAt ?? createdAt ?? now,
|
|
43
|
+
messages,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function normalizeRole(role) {
|
|
47
|
+
if (role === "user" || role === "assistant" || role === "system" || role === "tool")
|
|
48
|
+
return role;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
function extractClaudeText(record) {
|
|
52
|
+
const direct = stringValue(record.message);
|
|
53
|
+
if (direct)
|
|
54
|
+
return direct;
|
|
55
|
+
const message = asRecord(record.message);
|
|
56
|
+
if (!message)
|
|
57
|
+
return null;
|
|
58
|
+
const content = message.content;
|
|
59
|
+
if (typeof content === "string")
|
|
60
|
+
return content;
|
|
61
|
+
if (!Array.isArray(content))
|
|
62
|
+
return null;
|
|
63
|
+
const parts = content.flatMap((item) => {
|
|
64
|
+
const part = asRecord(item);
|
|
65
|
+
const text = part ? stringValue(part.text) : null;
|
|
66
|
+
return text ? [text] : [];
|
|
67
|
+
});
|
|
68
|
+
return parts.length > 0 ? parts.join("\n\n") : null;
|
|
69
|
+
}
|
|
70
|
+
function shouldDropScaffolding(text) {
|
|
71
|
+
return text.startsWith("<command-name>") || text.startsWith("<local-command-stdout>");
|
|
72
|
+
}
|
|
73
|
+
function firstLine(text) {
|
|
74
|
+
return text.split(/\r?\n/, 1)[0]?.slice(0, 80) || "Claude Code conversation";
|
|
75
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { redactSecrets } from "../redact/secrets.js";
|
|
3
|
+
import { asRecord, readJsonl, stringValue } from "./jsonl.js";
|
|
4
|
+
export async function parseCodexSession(path) {
|
|
5
|
+
const records = await readJsonl(path);
|
|
6
|
+
const messages = [];
|
|
7
|
+
let sessionId = basename(path, ".jsonl");
|
|
8
|
+
let title = null;
|
|
9
|
+
let createdAt = null;
|
|
10
|
+
let updatedAt = null;
|
|
11
|
+
for (const value of records) {
|
|
12
|
+
const record = asRecord(value);
|
|
13
|
+
if (!record)
|
|
14
|
+
continue;
|
|
15
|
+
const type = stringValue(record.type);
|
|
16
|
+
const timestamp = stringValue(record.timestamp) ?? stringValue(record.created_at);
|
|
17
|
+
if (timestamp) {
|
|
18
|
+
createdAt ??= timestamp;
|
|
19
|
+
updatedAt = timestamp;
|
|
20
|
+
}
|
|
21
|
+
if (type === "session_meta") {
|
|
22
|
+
sessionId = stringValue(record.id) ?? stringValue(record.session_id) ?? sessionId;
|
|
23
|
+
title = redactTitle(stringValue(record.title)) ?? title;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (type !== "event_msg")
|
|
27
|
+
continue;
|
|
28
|
+
const payload = asRecord(record.payload) ?? record;
|
|
29
|
+
const messageType = stringValue(payload.type) ?? stringValue(payload.kind);
|
|
30
|
+
const text = extractText(payload);
|
|
31
|
+
if (!text)
|
|
32
|
+
continue;
|
|
33
|
+
const role = roleForCodexMessage(messageType, payload);
|
|
34
|
+
messages.push({ role, content: text, timestamp });
|
|
35
|
+
if (!title && role === "user")
|
|
36
|
+
title = redactTitle(firstLine(text));
|
|
37
|
+
}
|
|
38
|
+
if (messages.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
const now = new Date().toISOString();
|
|
41
|
+
return {
|
|
42
|
+
id: sessionId,
|
|
43
|
+
sourceKind: "codex",
|
|
44
|
+
sourceId: path,
|
|
45
|
+
title: title ?? "Codex conversation",
|
|
46
|
+
createdAt: createdAt ?? now,
|
|
47
|
+
updatedAt: updatedAt ?? createdAt ?? now,
|
|
48
|
+
messages,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function roleForCodexMessage(messageType, payload) {
|
|
52
|
+
const role = stringValue(payload.role);
|
|
53
|
+
if (role === "user" || role === "assistant" || role === "system" || role === "tool")
|
|
54
|
+
return role;
|
|
55
|
+
if (messageType?.includes("user"))
|
|
56
|
+
return "user";
|
|
57
|
+
if (messageType?.includes("agent") || messageType?.includes("assistant"))
|
|
58
|
+
return "assistant";
|
|
59
|
+
if (messageType?.includes("tool"))
|
|
60
|
+
return "tool";
|
|
61
|
+
return "assistant";
|
|
62
|
+
}
|
|
63
|
+
function extractText(payload) {
|
|
64
|
+
for (const key of ["message", "text", "content", "delta"]) {
|
|
65
|
+
const direct = stringValue(payload[key]);
|
|
66
|
+
if (direct)
|
|
67
|
+
return direct;
|
|
68
|
+
}
|
|
69
|
+
const message = asRecord(payload.message);
|
|
70
|
+
if (message) {
|
|
71
|
+
const content = stringValue(message.content);
|
|
72
|
+
if (content)
|
|
73
|
+
return content;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function firstLine(text) {
|
|
78
|
+
return text.split(/\r?\n/, 1)[0]?.slice(0, 80) || "Codex conversation";
|
|
79
|
+
}
|
|
80
|
+
function redactTitle(title) {
|
|
81
|
+
return title ? redactSecrets(title) : null;
|
|
82
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
export async function readJsonl(path) {
|
|
3
|
+
const content = await readFile(path, "utf8");
|
|
4
|
+
const records = [];
|
|
5
|
+
for (const line of content.split(/\r?\n/)) {
|
|
6
|
+
const trimmed = line.trim();
|
|
7
|
+
if (!trimmed)
|
|
8
|
+
continue;
|
|
9
|
+
try {
|
|
10
|
+
records.push(JSON.parse(trimmed));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// Agent history files are written while active. A trailing partial JSONL
|
|
14
|
+
// record should not prevent earlier complete records from syncing.
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return records;
|
|
18
|
+
}
|
|
19
|
+
export function asRecord(value) {
|
|
20
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
21
|
+
}
|
|
22
|
+
export function stringValue(value) {
|
|
23
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const secretReplacement = "[REDACTED]";
|
|
2
|
+
const literalIndicators = [
|
|
3
|
+
"ghp_",
|
|
4
|
+
"github_pat_",
|
|
5
|
+
"sk-",
|
|
6
|
+
];
|
|
7
|
+
const caseInsensitiveIndicators = [
|
|
8
|
+
"bearer ",
|
|
9
|
+
"begin private key",
|
|
10
|
+
"begin rsa private key",
|
|
11
|
+
"begin ec private key",
|
|
12
|
+
"begin openssh private key",
|
|
13
|
+
"token=",
|
|
14
|
+
"key=",
|
|
15
|
+
"api_key=",
|
|
16
|
+
"password=",
|
|
17
|
+
"secret=",
|
|
18
|
+
];
|
|
19
|
+
const fullReplacementPatterns = [
|
|
20
|
+
/\bghp_[A-Za-z0-9_]{20,}\b/g,
|
|
21
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
22
|
+
/\bsk-[A-Za-z0-9_-]{16,}\b/g,
|
|
23
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi,
|
|
24
|
+
/-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/gi,
|
|
25
|
+
];
|
|
26
|
+
const assignmentPattern = /\b([A-Za-z0-9_]*(?:token|api_key|password|secret)|key)\s*=\s*([^\s&;,'"`]+)/gi;
|
|
27
|
+
export function redactSecrets(text) {
|
|
28
|
+
if (!mightContainSecret(text))
|
|
29
|
+
return text;
|
|
30
|
+
const withoutWholeSecrets = fullReplacementPatterns.reduce((result, pattern) => result.replace(pattern, secretReplacement), text);
|
|
31
|
+
return withoutWholeSecrets.replace(assignmentPattern, (_match, key) => `${key}=${secretReplacement}`);
|
|
32
|
+
}
|
|
33
|
+
export function mightContainSecret(text) {
|
|
34
|
+
if (literalIndicators.some((indicator) => text.includes(indicator)))
|
|
35
|
+
return true;
|
|
36
|
+
const lowered = text.toLowerCase();
|
|
37
|
+
return caseInsensitiveIndicators.some((indicator) => lowered.includes(indicator));
|
|
38
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { getDaemonPaths } from "../config/paths.js";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
export async function installUserService(paths = getDaemonPaths()) {
|
|
7
|
+
await mkdir(paths.systemdUserDir, { recursive: true });
|
|
8
|
+
const service = buildUserServiceFile(process.execPath, process.argv[1] ?? "nessie-daemon");
|
|
9
|
+
await writeFile(paths.systemdServiceFile, service);
|
|
10
|
+
await systemctl("daemon-reload");
|
|
11
|
+
await systemctl("enable", "nessie-daemon.service");
|
|
12
|
+
await systemctl("start", "nessie-daemon.service");
|
|
13
|
+
}
|
|
14
|
+
export function buildUserServiceFile(nodePath, cliPath) {
|
|
15
|
+
return [
|
|
16
|
+
"[Unit]",
|
|
17
|
+
"Description=Nessie daemon",
|
|
18
|
+
"Wants=network-online.target",
|
|
19
|
+
"After=network-online.target",
|
|
20
|
+
"",
|
|
21
|
+
"[Service]",
|
|
22
|
+
"Type=simple",
|
|
23
|
+
`ExecStart=${systemdQuote(nodePath)} ${systemdQuote(cliPath)} run`,
|
|
24
|
+
"Restart=always",
|
|
25
|
+
"RestartSec=30",
|
|
26
|
+
"",
|
|
27
|
+
"[Install]",
|
|
28
|
+
"WantedBy=default.target",
|
|
29
|
+
"",
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
32
|
+
export async function systemctl(...args) {
|
|
33
|
+
const { stdout, stderr } = await execFileAsync("systemctl", ["--user", ...args]);
|
|
34
|
+
return stdout || stderr;
|
|
35
|
+
}
|
|
36
|
+
function systemdQuote(value) {
|
|
37
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { homedir, hostname } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export async function detectSources() {
|
|
5
|
+
return detectSourcesInHome(homedir(), hostname());
|
|
6
|
+
}
|
|
7
|
+
export async function detectSourcesInHome(home, host) {
|
|
8
|
+
const candidates = [
|
|
9
|
+
{
|
|
10
|
+
kind: "claude_code",
|
|
11
|
+
displayName: `Claude Code on ${host}`,
|
|
12
|
+
basePath: join(home, ".claude"),
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
kind: "codex",
|
|
16
|
+
displayName: `Codex on ${host}`,
|
|
17
|
+
basePath: join(home, ".codex"),
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
const detected = [];
|
|
21
|
+
for (const candidate of candidates) {
|
|
22
|
+
if (await exists(sourceProbePath(candidate)))
|
|
23
|
+
detected.push(candidate);
|
|
24
|
+
}
|
|
25
|
+
return detected;
|
|
26
|
+
}
|
|
27
|
+
function sourceProbePath(source) {
|
|
28
|
+
switch (source.kind) {
|
|
29
|
+
case "claude_code":
|
|
30
|
+
return join(source.basePath, "projects");
|
|
31
|
+
case "codex":
|
|
32
|
+
return join(source.basePath, "sessions");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function exists(path) {
|
|
36
|
+
try {
|
|
37
|
+
await access(path);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ApiError, postJson } from "../api/http.js";
|
|
2
|
+
import { refreshAccessToken } from "../auth/tokens.js";
|
|
3
|
+
import { writeConfig } from "../config/store.js";
|
|
4
|
+
export async function runPreflightV3(config, options = {}) {
|
|
5
|
+
return postJsonWithRefresh(config, "/sync/v3/preflight", {
|
|
6
|
+
device: {
|
|
7
|
+
id: config.deviceId,
|
|
8
|
+
displayName: config.deviceName,
|
|
9
|
+
},
|
|
10
|
+
resources: config.selectedSources.map((source) => ({
|
|
11
|
+
kind: source.kind,
|
|
12
|
+
localId: source.localId,
|
|
13
|
+
stableKey: source.stableKey,
|
|
14
|
+
displayName: source.displayName,
|
|
15
|
+
})),
|
|
16
|
+
}, options);
|
|
17
|
+
}
|
|
18
|
+
export async function pushSyncPayload(config, payload, options = {}) {
|
|
19
|
+
return postJsonWithRefresh(config, "/sync/v2/push", {
|
|
20
|
+
deviceId: config.deviceId,
|
|
21
|
+
...payload,
|
|
22
|
+
}, options);
|
|
23
|
+
}
|
|
24
|
+
async function postJsonWithRefresh(config, path, body, options) {
|
|
25
|
+
try {
|
|
26
|
+
await postJson(config.apiUrl, path, body, { token: config.accessToken, fetchImpl: options.fetchImpl });
|
|
27
|
+
return config;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (!(error instanceof ApiError) || error.status !== 401)
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
const refreshed = await refreshAccessToken(config, { fetchImpl: options.fetchImpl });
|
|
34
|
+
await (options.persistConfig ?? writeConfig)(refreshed);
|
|
35
|
+
await postJson(refreshed.apiUrl, path, body, { token: refreshed.accessToken, fetchImpl: options.fetchImpl });
|
|
36
|
+
return refreshed;
|
|
37
|
+
}
|
package/dist/sync/ids.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function stableUuidFromString(input) {
|
|
3
|
+
const bytes = createHash("sha256").update(input).digest().subarray(0, 16);
|
|
4
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x80;
|
|
5
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
6
|
+
const hex = bytes.toString("hex");
|
|
7
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
8
|
+
}
|
|
9
|
+
export function contentHash(content) {
|
|
10
|
+
return createHash("sha256").update(content).digest("hex");
|
|
11
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { AuthRefreshError } from "../auth/tokens.js";
|
|
3
|
+
import { readConfig, readState, writeState } from "../config/store.js";
|
|
4
|
+
import { runPreflightV3, pushSyncPayload } from "./client.js";
|
|
5
|
+
import { mapSessionsToPushItems } from "./map.js";
|
|
6
|
+
import { scanSelectedSources } from "./scan.js";
|
|
7
|
+
export async function runSyncOnce() {
|
|
8
|
+
let config = await readConfig();
|
|
9
|
+
const state = await readState();
|
|
10
|
+
config = await runPreflightV3(config);
|
|
11
|
+
const scan = await scanSelectedSources(config.selectedSources, state);
|
|
12
|
+
const nodes = [];
|
|
13
|
+
const edges = [];
|
|
14
|
+
const previouslyPushedRoots = new Set(state.pushedSourceRootIds);
|
|
15
|
+
const pushedSourceRootIds = new Set(previouslyPushedRoots);
|
|
16
|
+
for (const source of config.selectedSources) {
|
|
17
|
+
const sessions = scan.sessionsBySourceId.get(source.localId) ?? [];
|
|
18
|
+
const includeRoot = !previouslyPushedRoots.has(source.localId);
|
|
19
|
+
const mapped = mapSessionsToPushItems(source, sessions, { includeRoot });
|
|
20
|
+
nodes.push(...mapped.nodes);
|
|
21
|
+
edges.push(...mapped.edges);
|
|
22
|
+
if (includeRoot)
|
|
23
|
+
pushedSourceRootIds.add(source.localId);
|
|
24
|
+
}
|
|
25
|
+
if (nodes.length > 0 || edges.length > 0) {
|
|
26
|
+
config = await pushSyncPayload(config, { nodes, edges });
|
|
27
|
+
}
|
|
28
|
+
await writeState({
|
|
29
|
+
...scan.nextState,
|
|
30
|
+
pushedSourceRootIds: [...pushedSourceRootIds].sort(),
|
|
31
|
+
lastSyncAt: new Date().toISOString(),
|
|
32
|
+
});
|
|
33
|
+
return { nodes: nodes.length, edges: edges.length };
|
|
34
|
+
}
|
|
35
|
+
export async function runSyncLoop(intervalSeconds) {
|
|
36
|
+
while (true) {
|
|
37
|
+
try {
|
|
38
|
+
const result = await runSyncOnce();
|
|
39
|
+
console.log(`Synced ${result.nodes} nodes and ${result.edges} edges.`);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
43
|
+
if (error instanceof AuthRefreshError)
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
await sleep(Math.max(5, intervalSeconds) * 1000);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/sync/map.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { redactSecrets } from "../redact/secrets.js";
|
|
2
|
+
import { contentHash, stableUuidFromString } from "./ids.js";
|
|
3
|
+
export function mapSessionsToPushItems(source, sessions, options = { includeRoot: true }) {
|
|
4
|
+
const nodes = options.includeRoot ? [mapRootNode(source)] : [];
|
|
5
|
+
const edges = [];
|
|
6
|
+
for (const session of sessions) {
|
|
7
|
+
const conversationId = stableUuidFromString(`${source.localId}:session:${session.id}`);
|
|
8
|
+
nodes.push({
|
|
9
|
+
node: {
|
|
10
|
+
id: conversationId,
|
|
11
|
+
integrationId: source.localId,
|
|
12
|
+
sourceId: session.sourceId,
|
|
13
|
+
name: redactSecrets(session.title),
|
|
14
|
+
kind: session.sourceKind === "codex" ? "codex_chat" : "claude_code_chat",
|
|
15
|
+
originalCreatedAt: session.createdAt,
|
|
16
|
+
originalUpdatedAt: session.updatedAt,
|
|
17
|
+
},
|
|
18
|
+
baseCloudUpdatedAt: null,
|
|
19
|
+
slices: [],
|
|
20
|
+
aiChatMessage: null,
|
|
21
|
+
nessieChatMessage: null,
|
|
22
|
+
});
|
|
23
|
+
edges.push(mapEdge(source.localId, conversationId, "source_of", 0));
|
|
24
|
+
const pushableMessages = session.messages.filter((message) => message.role !== "system");
|
|
25
|
+
pushableMessages.forEach((message, index) => {
|
|
26
|
+
const messageId = stableUuidFromString(`${conversationId}:message:${index}`);
|
|
27
|
+
const content = redactSecrets(message.content);
|
|
28
|
+
const timestamp = message.timestamp ?? session.updatedAt;
|
|
29
|
+
nodes.push({
|
|
30
|
+
node: {
|
|
31
|
+
id: messageId,
|
|
32
|
+
integrationId: source.localId,
|
|
33
|
+
sourceId: `${session.sourceId}#${index}`,
|
|
34
|
+
name: content.slice(0, 80) || message.role,
|
|
35
|
+
kind: session.sourceKind === "codex" ? "codex_chat_message" : "claude_code_chat_message",
|
|
36
|
+
originalCreatedAt: timestamp,
|
|
37
|
+
originalUpdatedAt: timestamp,
|
|
38
|
+
},
|
|
39
|
+
baseCloudUpdatedAt: null,
|
|
40
|
+
slices: [{
|
|
41
|
+
id: stableUuidFromString(`${messageId}:slice:0`),
|
|
42
|
+
nodeId: messageId,
|
|
43
|
+
index: 0,
|
|
44
|
+
content,
|
|
45
|
+
contentHash: contentHash(content),
|
|
46
|
+
}],
|
|
47
|
+
aiChatMessage: {
|
|
48
|
+
nodeId: messageId,
|
|
49
|
+
role: message.role,
|
|
50
|
+
author: null,
|
|
51
|
+
},
|
|
52
|
+
nessieChatMessage: null,
|
|
53
|
+
});
|
|
54
|
+
edges.push(mapEdge(conversationId, messageId, "contains", index));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return { nodes, edges };
|
|
58
|
+
}
|
|
59
|
+
function mapRootNode(source) {
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
return {
|
|
62
|
+
node: {
|
|
63
|
+
id: source.localId,
|
|
64
|
+
integrationId: source.localId,
|
|
65
|
+
sourceId: source.basePath,
|
|
66
|
+
name: redactSecrets(source.displayName),
|
|
67
|
+
kind: "integration_root",
|
|
68
|
+
originalCreatedAt: now,
|
|
69
|
+
originalUpdatedAt: now,
|
|
70
|
+
},
|
|
71
|
+
baseCloudUpdatedAt: null,
|
|
72
|
+
slices: [],
|
|
73
|
+
aiChatMessage: null,
|
|
74
|
+
nessieChatMessage: null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function mapEdge(fromNodeId, toNodeId, type, sortIndex) {
|
|
78
|
+
return {
|
|
79
|
+
edge: {
|
|
80
|
+
id: stableUuidFromString(`${fromNodeId}:${type}:${toNodeId}`),
|
|
81
|
+
fromNodeId,
|
|
82
|
+
toNodeId,
|
|
83
|
+
type,
|
|
84
|
+
sortIndex,
|
|
85
|
+
},
|
|
86
|
+
baseCloudUpdatedAt: null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseClaudeCodeSession } from "../parser/claudeCode.js";
|
|
4
|
+
import { parseCodexSession } from "../parser/codex.js";
|
|
5
|
+
export async function scanSelectedSources(sources, state) {
|
|
6
|
+
const nextState = {
|
|
7
|
+
files: { ...state.files },
|
|
8
|
+
pushedSourceRootIds: [...state.pushedSourceRootIds],
|
|
9
|
+
lastSyncAt: state.lastSyncAt,
|
|
10
|
+
};
|
|
11
|
+
const sessionsBySourceId = new Map();
|
|
12
|
+
for (const source of sources) {
|
|
13
|
+
const files = await sessionFilesForSource(source);
|
|
14
|
+
const sessions = [];
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
const cursor = await cursorForFile(file);
|
|
17
|
+
if (isUnchanged(state.files[file], cursor))
|
|
18
|
+
continue;
|
|
19
|
+
const session = source.kind === "codex" ? await parseCodexSession(file) : await parseClaudeCodeSession(file);
|
|
20
|
+
nextState.files[file] = cursor;
|
|
21
|
+
if (session)
|
|
22
|
+
sessions.push(session);
|
|
23
|
+
}
|
|
24
|
+
sessionsBySourceId.set(source.localId, sessions);
|
|
25
|
+
}
|
|
26
|
+
return { sessionsBySourceId, nextState };
|
|
27
|
+
}
|
|
28
|
+
async function sessionFilesForSource(source) {
|
|
29
|
+
const root = source.kind === "codex" ? join(source.basePath, "sessions") : join(source.basePath, "projects");
|
|
30
|
+
return walkJsonl(root);
|
|
31
|
+
}
|
|
32
|
+
async function walkJsonl(root) {
|
|
33
|
+
try {
|
|
34
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
35
|
+
const nested = await Promise.all(entries.map(async (entry) => {
|
|
36
|
+
const path = join(root, entry.name);
|
|
37
|
+
if (entry.isDirectory())
|
|
38
|
+
return walkJsonl(path);
|
|
39
|
+
return entry.isFile() && entry.name.endsWith(".jsonl") ? [path] : [];
|
|
40
|
+
}));
|
|
41
|
+
return nested.flat().sort();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
|
|
45
|
+
return [];
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function cursorForFile(file) {
|
|
50
|
+
const stats = await stat(file);
|
|
51
|
+
return {
|
|
52
|
+
mtimeMs: stats.mtimeMs,
|
|
53
|
+
size: stats.size,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function isUnchanged(previous, next) {
|
|
57
|
+
return previous?.mtimeMs === next.mtimeMs && previous.size === next.size;
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nessielabs/daemon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless Linux ingestion daemon for Nessie agent traces.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"nessie-daemon": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/**/*.js",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
22
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
23
|
+
"dev": "tsx src/cli.ts",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@inquirer/prompts": "^7.8.0",
|
|
28
|
+
"commander": "^14.0.2"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^24.10.1",
|
|
32
|
+
"tsx": "^4.20.6",
|
|
33
|
+
"typescript": "^5.9.3",
|
|
34
|
+
"vitest": "^4.0.14"
|
|
35
|
+
}
|
|
36
|
+
}
|