@nessielabs/daemon 0.3.0 → 0.3.2
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/dist/cli.js +1 -1
- package/dist/parser/claudeCode.js +5 -2
- package/dist/sync/batch.js +50 -0
- package/dist/sync/loop.js +11 -2
- package/dist/sync/map.js +4 -9
- package/dist/sync/slices.js +74 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ const program = new Command();
|
|
|
7
7
|
program
|
|
8
8
|
.name("nessie-daemon")
|
|
9
9
|
.description("Headless Nessie ingestion daemon for Linux agent machines.")
|
|
10
|
-
.version("0.
|
|
10
|
+
.version("0.3.1");
|
|
11
11
|
program
|
|
12
12
|
.command("setup")
|
|
13
13
|
.description("Authenticate, select local sources, and optionally install the user systemd service.")
|
|
@@ -4,7 +4,11 @@ import { asRecord, readJsonl, stringValue } from "./jsonl.js";
|
|
|
4
4
|
export async function parseClaudeCodeSession(path) {
|
|
5
5
|
const records = await readJsonl(path);
|
|
6
6
|
const messages = [];
|
|
7
|
-
|
|
7
|
+
// Anchor session identity to the JSONL filename, matching the macOS client.
|
|
8
|
+
// The in-file `sessionId` is reused across files for worktree/resumed
|
|
9
|
+
// sessions, so keying on it collapses distinct transcripts into one
|
|
10
|
+
// conversation id and makes their deterministic message ids collide (409).
|
|
11
|
+
const sessionId = basename(path, ".jsonl");
|
|
8
12
|
let title = null;
|
|
9
13
|
let createdAt = null;
|
|
10
14
|
let updatedAt = null;
|
|
@@ -19,7 +23,6 @@ export async function parseClaudeCodeSession(path) {
|
|
|
19
23
|
cwd ??= stringValue(record.cwd);
|
|
20
24
|
if (record.isSidechain === true)
|
|
21
25
|
continue;
|
|
22
|
-
sessionId = stringValue(record.sessionId) ?? stringValue(record.session_id) ?? sessionId;
|
|
23
26
|
const timestamp = stringValue(record.timestamp);
|
|
24
27
|
if (timestamp) {
|
|
25
28
|
createdAt ??= timestamp;
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
}
|
package/dist/sync/loop.js
CHANGED
|
@@ -3,6 +3,7 @@ import { AuthRefreshError } from "../auth/tokens.js";
|
|
|
3
3
|
import { readConfig, readState, writeState } from "../config/store.js";
|
|
4
4
|
import { GitRepoResolver } from "../git/repoResolver.js";
|
|
5
5
|
import { PARSER_VERSION } from "../parser/types.js";
|
|
6
|
+
import { planSyncPushBatches } from "./batch.js";
|
|
6
7
|
import { runPreflightV3, pushSyncPayload } from "./client.js";
|
|
7
8
|
import { mapSessionsToPushItems } from "./map.js";
|
|
8
9
|
import { scanSelectedSources } from "./scan.js";
|
|
@@ -39,8 +40,16 @@ export async function runSyncOnce() {
|
|
|
39
40
|
pushedSourceRootIds.add(source.localId);
|
|
40
41
|
Object.assign(pushedSessions, mapped.pushedSessions);
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
|
|
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);
|
|
44
53
|
}
|
|
45
54
|
await writeState({
|
|
46
55
|
...scan.nextState,
|
package/dist/sync/map.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { redactSecrets } from "../redact/secrets.js";
|
|
2
|
-
import {
|
|
2
|
+
import { stableUuidFromString } from "./ids.js";
|
|
3
|
+
import { buildContentSlices, takeGraphemes } from "./slices.js";
|
|
3
4
|
export function mapSessionsToPushItems(source, sessions, options = { includeRoot: true }) {
|
|
4
5
|
const pushedSessions = options.pushedSessions ?? {};
|
|
5
6
|
const nextPushedSessions = {};
|
|
@@ -55,19 +56,13 @@ export function mapSessionsToPushItems(source, sessions, options = { includeRoot
|
|
|
55
56
|
id: messageId,
|
|
56
57
|
integrationId: source.localId,
|
|
57
58
|
sourceId: `${session.sourceId}#${index}`,
|
|
58
|
-
name: content
|
|
59
|
+
name: takeGraphemes(content, 80) || message.role,
|
|
59
60
|
kind: session.sourceKind === "codex" ? "codex_chat_message" : "claude_code_chat_message",
|
|
60
61
|
originalCreatedAt: timestamp,
|
|
61
62
|
originalUpdatedAt: timestamp,
|
|
62
63
|
},
|
|
63
64
|
baseCloudUpdatedAt: null,
|
|
64
|
-
slices:
|
|
65
|
-
id: stableUuidFromString(`${messageId}:slice:0`),
|
|
66
|
-
nodeId: messageId,
|
|
67
|
-
index: 0,
|
|
68
|
-
content,
|
|
69
|
-
contentHash: contentHash(content),
|
|
70
|
-
}],
|
|
65
|
+
slices: buildContentSlices(messageId, content),
|
|
71
66
|
aiChatMessage: {
|
|
72
67
|
nodeId: messageId,
|
|
73
68
|
role: message.role,
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
}
|