@nessielabs/daemon 0.3.2 → 0.5.26

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.
Files changed (49) hide show
  1. package/README.md +8 -19
  2. package/dist/cli.js +46 -18
  3. package/dist/commands/auth.js +116 -0
  4. package/dist/commands/integrations.js +84 -0
  5. package/dist/commands/login.js +145 -0
  6. package/dist/commands/run.js +21 -6
  7. package/dist/commands/service.js +18 -36
  8. package/dist/commands/sharing.js +399 -0
  9. package/dist/commands/status.js +67 -0
  10. package/dist/commands/teams.js +64 -0
  11. package/dist/nera/client.js +156 -0
  12. package/dist/nera/process.js +78 -0
  13. package/dist/nera/runtimeFile.js +25 -0
  14. package/dist/platform/binary.js +21 -0
  15. package/dist/platform/openBrowser.js +14 -0
  16. package/dist/platform/paths.js +13 -0
  17. package/dist/platform/table.js +85 -0
  18. package/package.json +9 -3
  19. package/vendor/nera/darwin-arm64/nera +0 -0
  20. package/vendor/nera/darwin-x64/nera +0 -0
  21. package/vendor/nera/linux-arm64/nera +0 -0
  22. package/vendor/nera/linux-x64/nera +0 -0
  23. package/vendor/nera/win32-arm64/nera.exe +0 -0
  24. package/vendor/nera/win32-x64/nera.exe +0 -0
  25. package/vendor/proto/nessie.edge.v1.proto +475 -0
  26. package/dist/api/http.js +0 -25
  27. package/dist/auth/deviceFlow.js +0 -36
  28. package/dist/auth/tokens.js +0 -30
  29. package/dist/commands/setup.js +0 -78
  30. package/dist/config/endpoints.js +0 -2
  31. package/dist/config/paths.js +0 -19
  32. package/dist/config/store.js +0 -63
  33. package/dist/git/repoResolver.js +0 -215
  34. package/dist/parser/claudeCode.js +0 -84
  35. package/dist/parser/codex.js +0 -86
  36. package/dist/parser/jsonl.js +0 -24
  37. package/dist/parser/types.js +0 -9
  38. package/dist/redact/secrets.js +0 -60
  39. package/dist/service/process.js +0 -134
  40. package/dist/service/systemd.js +0 -38
  41. package/dist/sources/detect.js +0 -43
  42. package/dist/sync/batch.js +0 -50
  43. package/dist/sync/client.js +0 -47
  44. package/dist/sync/ids.js +0 -11
  45. package/dist/sync/loop.js +0 -76
  46. package/dist/sync/map.js +0 -111
  47. package/dist/sync/scan.js +0 -60
  48. package/dist/sync/slices.js +0 -74
  49. package/dist/sync/types.js +0 -1
@@ -1,38 +0,0 @@
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
- }
@@ -1,43 +0,0 @@
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
- }
@@ -1,50 +0,0 @@
1
- /**
2
- * Target number of content slices per node push request.
3
- *
4
- * Mirrors `CloudSyncPipeline.targetSlicesPerPush` in the macOS app. Batching by
5
- * slice count keeps each request well under Cloud Run's 32 MiB body limit, which
6
- * a single all-in-one push of a large cold sync would blow past (413).
7
- */
8
- export const TARGET_SLICES_PER_PUSH = 200;
9
- /**
10
- * Maximum edges per push request. Mirrors the macOS app's `edgeLimit`. Edges
11
- * carry only ids, so this bound is about request count, not size.
12
- */
13
- export const EDGE_PUSH_LIMIT = 500;
14
- /**
15
- * Split a push into request-sized batches, mirroring the macOS client.
16
- *
17
- * All node batches are emitted before any edge batch: edges carry foreign keys
18
- * to their endpoint nodes, so every node pushed in this sync must land before
19
- * the edges that reference it. Within a sync, node ordering is otherwise
20
- * irrelevant — `node.integration_id` is not a foreign key — so nodes are batched
21
- * purely by accumulated slice count.
22
- *
23
- * A node whose own slice count exceeds `targetSliceCount` is still emitted as
24
- * its own batch rather than dropped, matching the macOS app's "a single large
25
- * node is kept intact" behaviour.
26
- */
27
- export function planSyncPushBatches(nodes, edges, options = {}) {
28
- const targetSliceCount = options.targetSliceCount ?? TARGET_SLICES_PER_PUSH;
29
- const edgeLimit = options.edgeLimit ?? EDGE_PUSH_LIMIT;
30
- const batches = [];
31
- let currentNodes = [];
32
- let sliceCount = 0;
33
- for (const node of nodes) {
34
- const nodeSliceCount = node.slices.length;
35
- if (currentNodes.length > 0 && sliceCount + nodeSliceCount > targetSliceCount) {
36
- batches.push({ nodes: currentNodes, edges: [] });
37
- currentNodes = [];
38
- sliceCount = 0;
39
- }
40
- currentNodes.push(node);
41
- sliceCount += nodeSliceCount;
42
- }
43
- if (currentNodes.length > 0) {
44
- batches.push({ nodes: currentNodes, edges: [] });
45
- }
46
- for (let offset = 0; offset < edges.length; offset += edgeLimit) {
47
- batches.push({ nodes: [], edges: edges.slice(offset, offset + edgeLimit) });
48
- }
49
- return batches;
50
- }
@@ -1,47 +0,0 @@
1
- import { ApiError, postJson } from "../api/http.js";
2
- import { refreshAccessToken } from "../auth/tokens.js";
3
- import { syncApiUrl } from "../config/endpoints.js";
4
- import { writeConfig } from "../config/store.js";
5
- export async function runPreflightV3(config, options = {}) {
6
- return postJsonWithRefresh(config, "/sync/v3/preflight", {
7
- device: {
8
- id: config.deviceId,
9
- displayName: config.deviceName,
10
- },
11
- resources: config.selectedSources.map((source) => ({
12
- kind: source.kind,
13
- localId: source.localId,
14
- stableKey: source.stableKey,
15
- displayName: source.displayName,
16
- })),
17
- }, options);
18
- }
19
- export async function pushSyncPayload(config, payload, options = {}) {
20
- return postJsonWithRefresh(config, "/sync/v2/push", {
21
- deviceId: config.deviceId,
22
- ...payload,
23
- }, options);
24
- }
25
- async function postJsonWithRefresh(config, path, body, options) {
26
- const token = authToken(config);
27
- try {
28
- await postJson(syncApiUrl, path, body, { token, fetchImpl: options.fetchImpl });
29
- return config;
30
- }
31
- catch (error) {
32
- if (config.apiKey)
33
- throw error;
34
- if (!(error instanceof ApiError) || error.status !== 401)
35
- throw error;
36
- }
37
- const refreshed = await refreshAccessToken(config, { fetchImpl: options.fetchImpl });
38
- await (options.persistConfig ?? writeConfig)(refreshed);
39
- await postJson(syncApiUrl, path, body, { token: authToken(refreshed), fetchImpl: options.fetchImpl });
40
- return refreshed;
41
- }
42
- function authToken(config) {
43
- const token = config.apiKey ?? config.accessToken;
44
- if (!token)
45
- throw new Error("Daemon config is missing auth credentials. Run nessie-daemon setup.");
46
- return token;
47
- }
package/dist/sync/ids.js DELETED
@@ -1,11 +0,0 @@
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
- }
package/dist/sync/loop.js DELETED
@@ -1,76 +0,0 @@
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 { GitRepoResolver } from "../git/repoResolver.js";
5
- import { PARSER_VERSION } from "../parser/types.js";
6
- import { planSyncPushBatches } from "./batch.js";
7
- import { runPreflightV3, pushSyncPayload } from "./client.js";
8
- import { mapSessionsToPushItems } from "./map.js";
9
- import { scanSelectedSources } from "./scan.js";
10
- export async function runSyncOnce() {
11
- let config = await readConfig();
12
- const state = await readState();
13
- config = await runPreflightV3(config);
14
- // A parser version bump reparses everything once and re-pushes conversation
15
- // headers as field-masked merges so existing conversations pick up newly
16
- // extracted metadata. The state only records the new version after a
17
- // successful push, so a failed backfill retries on the next run.
18
- const backfillParserChanges = state.parserVersion < PARSER_VERSION;
19
- const scan = await scanSelectedSources(config.selectedSources, state, {
20
- forceReparse: backfillParserChanges,
21
- });
22
- const repoResolver = new GitRepoResolver();
23
- const nodes = [];
24
- const edges = [];
25
- const previouslyPushedRoots = new Set(state.pushedSourceRootIds);
26
- const pushedSourceRootIds = new Set(previouslyPushedRoots);
27
- const pushedSessions = { ...state.pushedSessions };
28
- for (const source of config.selectedSources) {
29
- const sessions = scan.sessionsBySourceId.get(source.localId) ?? [];
30
- const includeRoot = !previouslyPushedRoots.has(source.localId);
31
- const mapped = mapSessionsToPushItems(source, sessions, {
32
- includeRoot,
33
- pushedSessions: state.pushedSessions,
34
- repushHeaders: backfillParserChanges,
35
- resolveRepo: (workspacePath) => repoResolver.resolve(workspacePath),
36
- });
37
- nodes.push(...mapped.nodes);
38
- edges.push(...mapped.edges);
39
- if (includeRoot)
40
- pushedSourceRootIds.add(source.localId);
41
- Object.assign(pushedSessions, mapped.pushedSessions);
42
- }
43
- // Push in request-sized batches (all nodes before any edges) so a large cold
44
- // sync stays under Cloud Run's body limit. Each batch push is idempotent, and
45
- // sync state is only written after every batch succeeds, so a mid-stream
46
- // failure simply re-pushes the whole set on the next run.
47
- const batches = planSyncPushBatches(nodes, edges);
48
- for (const [index, batch] of batches.entries()) {
49
- // Per-batch logging keeps a long cold sync diagnosable when it would
50
- // otherwise be silent across hundreds of sequential requests.
51
- console.log(`Pushing sync batch ${index + 1}/${batches.length}: ${batch.nodes.length} nodes, ${batch.edges.length} edges.`);
52
- config = await pushSyncPayload(config, batch);
53
- }
54
- await writeState({
55
- ...scan.nextState,
56
- pushedSourceRootIds: [...pushedSourceRootIds].sort(),
57
- pushedSessions,
58
- parserVersion: PARSER_VERSION,
59
- lastSyncAt: new Date().toISOString(),
60
- });
61
- return { nodes: nodes.length, edges: edges.length };
62
- }
63
- export async function runSyncLoop(intervalSeconds) {
64
- while (true) {
65
- try {
66
- const result = await runSyncOnce();
67
- console.log(`Synced ${result.nodes} nodes and ${result.edges} edges.`);
68
- }
69
- catch (error) {
70
- console.error(error instanceof Error ? error.message : String(error));
71
- if (error instanceof AuthRefreshError)
72
- throw error;
73
- }
74
- await sleep(Math.max(5, intervalSeconds) * 1000);
75
- }
76
- }
package/dist/sync/map.js DELETED
@@ -1,111 +0,0 @@
1
- import { redactSecrets } from "../redact/secrets.js";
2
- import { stableUuidFromString } from "./ids.js";
3
- import { buildContentSlices, takeGraphemes } from "./slices.js";
4
- export function mapSessionsToPushItems(source, sessions, options = { includeRoot: true }) {
5
- const pushedSessions = options.pushedSessions ?? {};
6
- const nextPushedSessions = {};
7
- const nodes = options.includeRoot ? [mapRootNode(source)] : [];
8
- const edges = [];
9
- for (const session of sessions) {
10
- const conversationId = stableUuidFromString(`${source.localId}:session:${session.id}`);
11
- const stateKey = sessionStateKey(source, session);
12
- const sessionCursor = pushedSessions[stateKey] ?? {
13
- conversationPushed: false,
14
- pushedMessageCount: 0,
15
- };
16
- if (!sessionCursor.conversationPushed || options.repushHeaders === true) {
17
- const repoInfo = session.cwd !== null ? options.resolveRepo?.(session.cwd) ?? null : null;
18
- // Headers always push as a field-masked merge: new conversations insert
19
- // normally, and re-pushed headers only touch the workspace metadata
20
- // columns, never fields other clients own (name, labels, deletion).
21
- nodes.push({
22
- node: {
23
- id: conversationId,
24
- integrationId: source.localId,
25
- sourceId: session.sourceId,
26
- name: redactSecrets(session.title),
27
- kind: session.sourceKind === "codex" ? "codex_chat" : "claude_code_chat",
28
- workspacePath: session.cwd,
29
- repoRemoteUrl: repoInfo?.remoteUrl ?? null,
30
- repoKey: repoInfo?.repoKey ?? null,
31
- originalCreatedAt: session.createdAt,
32
- originalUpdatedAt: session.updatedAt,
33
- },
34
- baseCloudUpdatedAt: null,
35
- slices: [],
36
- aiChatMessage: null,
37
- nessieChatMessage: null,
38
- mergeFields: ["workspacePath", "repoRemoteUrl", "repoKey"],
39
- });
40
- }
41
- if (!sessionCursor.conversationPushed) {
42
- edges.push(mapEdge(source.localId, conversationId, "source_of", 0));
43
- }
44
- const pushableMessages = session.messages.filter((message) => message.role !== "system");
45
- nextPushedSessions[stateKey] = {
46
- conversationPushed: true,
47
- pushedMessageCount: pushableMessages.length,
48
- };
49
- pushableMessages.slice(sessionCursor.pushedMessageCount).forEach((message, offset) => {
50
- const index = sessionCursor.pushedMessageCount + offset;
51
- const messageId = stableUuidFromString(`${conversationId}:message:${index}`);
52
- const content = redactSecrets(message.content);
53
- const timestamp = message.timestamp ?? session.updatedAt;
54
- nodes.push({
55
- node: {
56
- id: messageId,
57
- integrationId: source.localId,
58
- sourceId: `${session.sourceId}#${index}`,
59
- name: takeGraphemes(content, 80) || message.role,
60
- kind: session.sourceKind === "codex" ? "codex_chat_message" : "claude_code_chat_message",
61
- originalCreatedAt: timestamp,
62
- originalUpdatedAt: timestamp,
63
- },
64
- baseCloudUpdatedAt: null,
65
- slices: buildContentSlices(messageId, content),
66
- aiChatMessage: {
67
- nodeId: messageId,
68
- role: message.role,
69
- author: null,
70
- },
71
- nessieChatMessage: null,
72
- });
73
- edges.push(mapEdge(conversationId, messageId, "contains", index));
74
- });
75
- }
76
- return { nodes, edges, pushedSessions: nextPushedSessions };
77
- }
78
- export function sessionStateKey(source, session) {
79
- return `${source.localId}:session:${session.id}`;
80
- }
81
- function mapRootNode(source) {
82
- const now = new Date().toISOString();
83
- return {
84
- node: {
85
- id: source.localId,
86
- integrationId: source.localId,
87
- sourceId: source.basePath,
88
- name: redactSecrets(source.displayName),
89
- kind: "integration_root",
90
- originalCreatedAt: now,
91
- originalUpdatedAt: now,
92
- },
93
- baseCloudUpdatedAt: null,
94
- slices: [],
95
- aiChatMessage: null,
96
- nessieChatMessage: null,
97
- };
98
- }
99
- function mapEdge(fromNodeId, toNodeId, type, sortIndex) {
100
- const id = stableUuidFromString(`${fromNodeId}:${type}:${toNodeId}`);
101
- return {
102
- edge: {
103
- id,
104
- fromNodeId,
105
- toNodeId,
106
- type,
107
- sortIndex,
108
- },
109
- baseCloudUpdatedAt: null,
110
- };
111
- }
package/dist/sync/scan.js DELETED
@@ -1,60 +0,0 @@
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, options = {}) {
6
- const nextState = {
7
- files: { ...state.files },
8
- pushedSourceRootIds: [...state.pushedSourceRootIds],
9
- pushedSessions: { ...state.pushedSessions },
10
- parserVersion: state.parserVersion,
11
- lastSyncAt: state.lastSyncAt,
12
- };
13
- const sessionsBySourceId = new Map();
14
- for (const source of sources) {
15
- const files = await sessionFilesForSource(source);
16
- const sessions = [];
17
- for (const file of files) {
18
- const cursor = await cursorForFile(file);
19
- if (options.forceReparse !== true && isUnchanged(state.files[file], cursor))
20
- continue;
21
- const session = source.kind === "codex" ? await parseCodexSession(file) : await parseClaudeCodeSession(file);
22
- nextState.files[file] = cursor;
23
- if (session)
24
- sessions.push(session);
25
- }
26
- sessionsBySourceId.set(source.localId, sessions);
27
- }
28
- return { sessionsBySourceId, nextState };
29
- }
30
- async function sessionFilesForSource(source) {
31
- const root = source.kind === "codex" ? join(source.basePath, "sessions") : join(source.basePath, "projects");
32
- return walkJsonl(root);
33
- }
34
- async function walkJsonl(root) {
35
- try {
36
- const entries = await readdir(root, { withFileTypes: true });
37
- const nested = await Promise.all(entries.map(async (entry) => {
38
- const path = join(root, entry.name);
39
- if (entry.isDirectory())
40
- return walkJsonl(path);
41
- return entry.isFile() && entry.name.endsWith(".jsonl") ? [path] : [];
42
- }));
43
- return nested.flat().sort();
44
- }
45
- catch (error) {
46
- if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
47
- return [];
48
- throw error;
49
- }
50
- }
51
- async function cursorForFile(file) {
52
- const stats = await stat(file);
53
- return {
54
- mtimeMs: stats.mtimeMs,
55
- size: stats.size,
56
- };
57
- }
58
- function isUnchanged(previous, next) {
59
- return previous?.mtimeMs === next.mtimeMs && previous.size === next.size;
60
- }
@@ -1,74 +0,0 @@
1
- import { contentHash, stableUuidFromString } from "./ids.js";
2
- /**
3
- * Default maximum character count for a content slice.
4
- *
5
- * Mirrors `NodeContentSlice.defaultContentSliceSize` in NessieCore so the
6
- * daemon produces the same slice granularity as the macOS client.
7
- */
8
- export const DEFAULT_CONTENT_SLICE_SIZE = 800;
9
- // Swift's `String.index(_:offsetBy:)` advances by `Character`, i.e. extended
10
- // grapheme clusters, so its 800-unit slices never split a grapheme. Segmenting
11
- // by grapheme here matches that behaviour rather than slicing by UTF-16 code
12
- // units, which would cut emoji and combining sequences in half. A fixed locale
13
- // keeps grapheme boundaries deterministic across daemon hosts regardless of the
14
- // process locale (e.g. LANG=C).
15
- const graphemeSegmenter = new Intl.Segmenter("en-US", { granularity: "grapheme" });
16
- /**
17
- * Split content into the canonical chunks used for node content slices.
18
- *
19
- * Ports `NodeContentSlice.splitContent` from NessieCore: empty content yields a
20
- * single empty slice, and non-empty content is chunked into `sliceSize`
21
- * grapheme runs.
22
- */
23
- export function splitContent(content, sliceSize = DEFAULT_CONTENT_SLICE_SIZE) {
24
- if (content === "")
25
- return [""];
26
- const pieces = [];
27
- let current = "";
28
- let count = 0;
29
- for (const { segment } of graphemeSegmenter.segment(content)) {
30
- current += segment;
31
- count += 1;
32
- if (count === sliceSize) {
33
- pieces.push(current);
34
- current = "";
35
- count = 0;
36
- }
37
- }
38
- if (count > 0)
39
- pieces.push(current);
40
- return pieces;
41
- }
42
- /**
43
- * Take the first `count` grapheme clusters of `content`.
44
- *
45
- * Used for short display names so a truncation boundary never severs an emoji
46
- * or combining sequence the way a UTF-16 `content.slice(0, count)` would.
47
- */
48
- export function takeGraphemes(content, count) {
49
- if (count <= 0)
50
- return "";
51
- let result = "";
52
- let taken = 0;
53
- for (const { segment } of graphemeSegmenter.segment(content)) {
54
- result += segment;
55
- taken += 1;
56
- if (taken === count)
57
- break;
58
- }
59
- return result;
60
- }
61
- /**
62
- * Build the content slices for a message node from its already-redacted
63
- * content. Slice 0 keeps the same stable id the daemon used when every message
64
- * was a single slice, so re-pushes stay idempotent.
65
- */
66
- export function buildContentSlices(nodeId, content) {
67
- return splitContent(content).map((piece, index) => ({
68
- id: stableUuidFromString(`${nodeId}:slice:${index}`),
69
- nodeId,
70
- index,
71
- content: piece,
72
- contentHash: contentHash(piece),
73
- }));
74
- }
@@ -1 +0,0 @@
1
- export {};