@nessielabs/daemon 0.3.2 → 0.4.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 +3 -5
- package/dist/api/http.js +19 -0
- package/dist/cli.js +8 -1
- package/dist/commands/setup.js +1 -4
- package/dist/config/paths.js +1 -1
- package/dist/config/store.js +0 -36
- package/dist/localStore/daemonGraphStore.js +494 -0
- package/dist/localStore/messageComparison.js +37 -0
- package/dist/localStore/pushMapping.js +69 -0
- package/dist/localStore/rowTypes.js +1 -0
- package/dist/localStore/schema.js +70 -0
- package/dist/localStore/uuid.js +12 -0
- package/dist/parser/claudeCode.js +139 -21
- package/dist/parser/codex.js +8 -3
- package/dist/parser/types.js +7 -1
- package/dist/sync/client.js +51 -8
- package/dist/sync/exclusions.js +10 -0
- package/dist/sync/hash.js +4 -0
- package/dist/sync/loop.js +83 -53
- package/dist/sync/scan.js +219 -34
- package/dist/sync/slices.js +3 -14
- package/package.json +3 -1
- package/dist/sync/ids.js +0 -11
- package/dist/sync/map.js +0 -111
package/dist/sync/loop.js
CHANGED
|
@@ -1,64 +1,94 @@
|
|
|
1
1
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
2
|
import { AuthRefreshError } from "../auth/tokens.js";
|
|
3
|
-
import {
|
|
3
|
+
import { getDaemonPaths } from "../config/paths.js";
|
|
4
|
+
import { readConfig } from "../config/store.js";
|
|
4
5
|
import { GitRepoResolver } from "../git/repoResolver.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
6
|
+
import { openDaemonGraphStore } from "../localStore/daemonGraphStore.js";
|
|
7
|
+
import { parserVersionForSourceKind } from "../parser/types.js";
|
|
8
|
+
import { EDGE_PUSH_LIMIT, TARGET_SLICES_PER_PUSH } from "./batch.js";
|
|
9
|
+
import { listSyncExclusions, pullCloudSyncPage, pushSyncPayload, runPreflightV4 } from "./client.js";
|
|
10
|
+
import { filterExcludedConversations } from "./exclusions.js";
|
|
11
|
+
import { fetchConversation, listSelectedSourceConversations } from "./scan.js";
|
|
12
|
+
const PULL_PAGE_LIMIT = 500;
|
|
13
|
+
const PULL_NODE_CURSOR_KEY = "daemonPullV4NodeCursor";
|
|
14
|
+
const PULL_EDGE_CURSOR_KEY = "daemonPullV4EdgeCursor";
|
|
10
15
|
export async function runSyncOnce() {
|
|
11
16
|
let config = await readConfig();
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
});
|
|
17
|
+
config = await runPreflightV4(config);
|
|
18
|
+
const exclusionsResult = await listSyncExclusions(config);
|
|
19
|
+
config = exclusionsResult.config;
|
|
20
|
+
const exclusions = exclusionsResult.response.exclusions;
|
|
22
21
|
const repoResolver = new GitRepoResolver();
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
22
|
+
const store = openDaemonGraphStore(getDaemonPaths().databaseFile);
|
|
23
|
+
try {
|
|
24
|
+
let nodeCursor = store.getSyncCursor(PULL_NODE_CURSOR_KEY);
|
|
25
|
+
let edgeCursor = store.getSyncCursor(PULL_EDGE_CURSOR_KEY);
|
|
26
|
+
while (true) {
|
|
27
|
+
const result = await pullCloudSyncPage(config, {
|
|
28
|
+
nodeCursor,
|
|
29
|
+
edgeCursor,
|
|
30
|
+
limit: PULL_PAGE_LIMIT,
|
|
31
|
+
});
|
|
32
|
+
config = result.config;
|
|
33
|
+
const pullResult = store.applyCloudPull(result.response);
|
|
34
|
+
if (pullResult.skippedEdges > 0) {
|
|
35
|
+
console.warn(`Skipped ${pullResult.skippedEdges} pulled edge(s) with missing endpoint nodes.`);
|
|
36
|
+
}
|
|
37
|
+
if (result.response.nextNodeCursor) {
|
|
38
|
+
nodeCursor = result.response.nextNodeCursor;
|
|
39
|
+
store.setSyncCursor(PULL_NODE_CURSOR_KEY, nodeCursor);
|
|
40
|
+
}
|
|
41
|
+
if (result.response.nextEdgeCursor) {
|
|
42
|
+
edgeCursor = result.response.nextEdgeCursor;
|
|
43
|
+
store.setSyncCursor(PULL_EDGE_CURSOR_KEY, edgeCursor);
|
|
44
|
+
}
|
|
45
|
+
if (!result.response.hasMoreNodes && !result.response.hasMoreEdges)
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
const listings = await listSelectedSourceConversations(config.selectedSources);
|
|
49
|
+
for (const { source, conversations } of listings) {
|
|
50
|
+
const parserVersion = parserVersionForSourceKind(source.kind);
|
|
51
|
+
const filteredConversations = filterExcludedConversations(conversations, exclusions);
|
|
52
|
+
const conversationsToFetch = store.conversationsNeedingFetch(source, filteredConversations, parserVersion);
|
|
53
|
+
for (const conversation of conversationsToFetch) {
|
|
54
|
+
const session = await fetchConversation(source, conversation);
|
|
55
|
+
if (session) {
|
|
56
|
+
store.persistSourceSessions(source, [session], {
|
|
57
|
+
parserVersion,
|
|
58
|
+
resolveRepo: (workspacePath) => repoResolver.resolve(workspacePath),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
let pushedNodes = 0;
|
|
64
|
+
let pushedEdges = 0;
|
|
65
|
+
let batchIndex = 0;
|
|
66
|
+
while (true) {
|
|
67
|
+
const batch = store.nextPushBatch({
|
|
68
|
+
targetSliceCount: TARGET_SLICES_PER_PUSH,
|
|
69
|
+
edgeLimit: EDGE_PUSH_LIMIT,
|
|
70
|
+
});
|
|
71
|
+
if (!batch)
|
|
72
|
+
break;
|
|
73
|
+
batchIndex += 1;
|
|
74
|
+
// Per-batch logging keeps a long cold sync diagnosable when it would
|
|
75
|
+
// otherwise be silent across hundreds of sequential requests.
|
|
76
|
+
console.log(`Pushing sync batch ${batchIndex}: ${batch.nodes.length} nodes, ${batch.edges.length} edges.`);
|
|
77
|
+
const result = await pushSyncPayload(config, { nodes: batch.nodes, edges: batch.edges });
|
|
78
|
+
config = result.config;
|
|
79
|
+
store.applyPushAcknowledgements(batch, result.response);
|
|
80
|
+
pushedNodes += batch.nodes.length;
|
|
81
|
+
pushedEdges += batch.edges.length;
|
|
82
|
+
}
|
|
83
|
+
store.setAppSetting("daemonLastSyncAt", new Date().toISOString());
|
|
84
|
+
for (const source of config.selectedSources) {
|
|
85
|
+
store.setAppSetting(`daemonParserVersion.${source.kind}`, String(parserVersionForSourceKind(source.kind)));
|
|
86
|
+
}
|
|
87
|
+
return { nodes: pushedNodes, edges: pushedEdges };
|
|
42
88
|
}
|
|
43
|
-
|
|
44
|
-
|
|
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);
|
|
89
|
+
finally {
|
|
90
|
+
store.close();
|
|
53
91
|
}
|
|
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
92
|
}
|
|
63
93
|
export async function runSyncLoop(intervalSeconds) {
|
|
64
94
|
while (true) {
|
package/dist/sync/scan.js
CHANGED
|
@@ -1,35 +1,177 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { open, readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, join, relative } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { asRecord, stringValue } from "../parser/jsonl.js";
|
|
3
6
|
import { parseClaudeCodeSession } from "../parser/claudeCode.js";
|
|
4
7
|
import { parseCodexSession } from "../parser/codex.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
const CODEX_ROOTS = [
|
|
9
|
+
{ directoryName: "sessions", priority: 0 },
|
|
10
|
+
{ directoryName: "archived_sessions", priority: 1 },
|
|
11
|
+
];
|
|
12
|
+
export async function listSelectedSourceConversations(sources) {
|
|
13
|
+
return Promise.all(sources.map(async (source) => ({
|
|
14
|
+
source,
|
|
15
|
+
conversations: source.kind === "codex"
|
|
16
|
+
? await listCodexConversations(source)
|
|
17
|
+
: await listClaudeCodeConversations(source),
|
|
18
|
+
})));
|
|
19
|
+
}
|
|
20
|
+
export async function fetchConversation(source, conversation) {
|
|
21
|
+
const parsed = source.kind === "codex"
|
|
22
|
+
? await parseCodexSession(conversation.path)
|
|
23
|
+
: await parseClaudeCodeSession(conversation.path);
|
|
24
|
+
if (!parsed)
|
|
25
|
+
return null;
|
|
26
|
+
return {
|
|
27
|
+
...parsed,
|
|
28
|
+
sourceId: conversation.sourceId,
|
|
29
|
+
updatedAt: conversation.lastMessageTime ?? conversation.updatedAt ?? parsed.updatedAt,
|
|
30
|
+
cwd: conversation.cwd ?? parsed.cwd,
|
|
12
31
|
};
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
}
|
|
33
|
+
async function listCodexConversations(source) {
|
|
34
|
+
const importedThreadIds = await externalImportedThreadIds(source.basePath);
|
|
35
|
+
const refs = [];
|
|
36
|
+
for (const root of CODEX_ROOTS) {
|
|
37
|
+
const rootPath = join(source.basePath, root.directoryName);
|
|
38
|
+
const files = await walkJsonl(rootPath);
|
|
39
|
+
for (const path of files) {
|
|
40
|
+
const summary = await summarizeCodexSession(path);
|
|
41
|
+
if (!summary.isConversation)
|
|
20
42
|
continue;
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
43
|
+
const relativePath = relative(rootPath, path);
|
|
44
|
+
const fallbackSessionId = basename(relativePath, ".jsonl");
|
|
45
|
+
const sessionId = summary.sessionId ?? fallbackSessionId;
|
|
46
|
+
if (importedThreadIds.has(sessionId))
|
|
47
|
+
continue;
|
|
48
|
+
const attrs = await stat(path);
|
|
49
|
+
refs.push({
|
|
50
|
+
sourceLocalId: source.localId,
|
|
51
|
+
sourceKind: "codex",
|
|
52
|
+
sourceId: sessionId,
|
|
53
|
+
path,
|
|
54
|
+
updatedAt: attrs.mtime.toISOString(),
|
|
55
|
+
lastMessageTime: await peekLastMessageTime(path, isCodexMessageRecord),
|
|
56
|
+
cwd: summary.cwd,
|
|
57
|
+
rootPriority: root.priority,
|
|
58
|
+
});
|
|
25
59
|
}
|
|
26
|
-
sessionsBySourceId.set(source.localId, sessions);
|
|
27
60
|
}
|
|
28
|
-
return
|
|
61
|
+
return deduplicateCodexRefs(refs).sort((left, right) => recencyMillis(right) - recencyMillis(left));
|
|
62
|
+
}
|
|
63
|
+
async function listClaudeCodeConversations(source) {
|
|
64
|
+
const projectsPath = join(source.basePath, "projects");
|
|
65
|
+
let projectDirs;
|
|
66
|
+
try {
|
|
67
|
+
projectDirs = await readdir(projectsPath, { withFileTypes: true });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (isNotFound(error))
|
|
71
|
+
return [];
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
const refs = [];
|
|
75
|
+
for (const projectDir of projectDirs.filter((entry) => entry.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
76
|
+
const projectPath = join(projectsPath, projectDir.name);
|
|
77
|
+
let entries;
|
|
78
|
+
try {
|
|
79
|
+
entries = await readdir(projectPath, { withFileTypes: true });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".jsonl"))) {
|
|
85
|
+
const path = join(projectPath, entry.name);
|
|
86
|
+
const attrs = await stat(path);
|
|
87
|
+
refs.push({
|
|
88
|
+
sourceLocalId: source.localId,
|
|
89
|
+
sourceKind: "claude_code",
|
|
90
|
+
sourceId: basename(entry.name, ".jsonl"),
|
|
91
|
+
path,
|
|
92
|
+
updatedAt: attrs.mtime.toISOString(),
|
|
93
|
+
lastMessageTime: await peekLastMessageTime(path, isClaudeCodeMessageRecord),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return refs.sort((left, right) => recencyMillis(right) - recencyMillis(left));
|
|
98
|
+
}
|
|
99
|
+
async function summarizeCodexSession(path) {
|
|
100
|
+
let sessionId = null;
|
|
101
|
+
let cwd = null;
|
|
102
|
+
let hasUser = false;
|
|
103
|
+
let hasAssistant = false;
|
|
104
|
+
const stream = createReadStream(path, { encoding: "utf8" });
|
|
105
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
106
|
+
try {
|
|
107
|
+
for await (const line of lines) {
|
|
108
|
+
const record = parseJsonRecord(line);
|
|
109
|
+
if (!record)
|
|
110
|
+
continue;
|
|
111
|
+
if (stringValue(record.type) === "session_meta") {
|
|
112
|
+
const payload = asRecord(record.payload);
|
|
113
|
+
sessionId ??= stringValue(payload?.id) ?? stringValue(record.id) ?? stringValue(record.session_id);
|
|
114
|
+
cwd ??= stringValue(payload?.cwd) ?? stringValue(record.cwd);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!isCodexMessageRecord(record))
|
|
118
|
+
continue;
|
|
119
|
+
const role = codexRecordRole(record);
|
|
120
|
+
hasUser ||= role === "user";
|
|
121
|
+
hasAssistant ||= role === "assistant";
|
|
122
|
+
if (hasUser && hasAssistant) {
|
|
123
|
+
lines.close();
|
|
124
|
+
stream.destroy();
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { sessionId, cwd, isConversation: false };
|
|
131
|
+
}
|
|
132
|
+
return { sessionId, cwd, isConversation: hasUser && hasAssistant };
|
|
29
133
|
}
|
|
30
|
-
async function
|
|
31
|
-
|
|
32
|
-
|
|
134
|
+
async function externalImportedThreadIds(basePath) {
|
|
135
|
+
try {
|
|
136
|
+
const content = await readFile(join(basePath, "external_agent_session_imports.json"), "utf8");
|
|
137
|
+
const parsed = JSON.parse(content);
|
|
138
|
+
const records = asRecord(parsed)?.records;
|
|
139
|
+
if (!Array.isArray(records))
|
|
140
|
+
return new Set();
|
|
141
|
+
return new Set(records.flatMap((record) => {
|
|
142
|
+
const id = stringValue(asRecord(record)?.imported_thread_id);
|
|
143
|
+
return id ? [id] : [];
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return new Set();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function peekLastMessageTime(path, isMessageRecord) {
|
|
151
|
+
let file;
|
|
152
|
+
try {
|
|
153
|
+
file = await open(path, "r");
|
|
154
|
+
const { size } = await file.stat();
|
|
155
|
+
const readSize = Math.min(size, 50_000);
|
|
156
|
+
const buffer = Buffer.alloc(readSize);
|
|
157
|
+
await file.read(buffer, 0, readSize, size - readSize);
|
|
158
|
+
const tail = buffer.toString("utf8");
|
|
159
|
+
for (const line of tail.split(/\r?\n/).reverse()) {
|
|
160
|
+
const record = parseJsonRecord(line);
|
|
161
|
+
if (!record || !isMessageRecord(record))
|
|
162
|
+
continue;
|
|
163
|
+
const timestamp = stringValue(record.timestamp) ?? stringValue(record.created_at);
|
|
164
|
+
if (timestamp)
|
|
165
|
+
return timestamp;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
await file?.close();
|
|
174
|
+
}
|
|
33
175
|
}
|
|
34
176
|
async function walkJsonl(root) {
|
|
35
177
|
try {
|
|
@@ -43,18 +185,61 @@ async function walkJsonl(root) {
|
|
|
43
185
|
return nested.flat().sort();
|
|
44
186
|
}
|
|
45
187
|
catch (error) {
|
|
46
|
-
if (
|
|
188
|
+
if (isNotFound(error))
|
|
47
189
|
return [];
|
|
48
190
|
throw error;
|
|
49
191
|
}
|
|
50
192
|
}
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
193
|
+
function deduplicateCodexRefs(refs) {
|
|
194
|
+
const selected = new Map();
|
|
195
|
+
for (const ref of refs) {
|
|
196
|
+
const existing = selected.get(ref.sourceId);
|
|
197
|
+
if (!existing || shouldPreferCodexRef(ref, existing))
|
|
198
|
+
selected.set(ref.sourceId, ref);
|
|
199
|
+
}
|
|
200
|
+
return [...selected.values()].map(({ rootPriority: _rootPriority, ...ref }) => ref);
|
|
201
|
+
}
|
|
202
|
+
function shouldPreferCodexRef(candidate, existing) {
|
|
203
|
+
if (candidate.rootPriority !== existing.rootPriority)
|
|
204
|
+
return candidate.rootPriority < existing.rootPriority;
|
|
205
|
+
return recencyMillis(candidate) > recencyMillis(existing);
|
|
206
|
+
}
|
|
207
|
+
function recencyMillis(ref) {
|
|
208
|
+
return Date.parse(ref.lastMessageTime ?? ref.updatedAt ?? "") || 0;
|
|
209
|
+
}
|
|
210
|
+
function parseJsonRecord(line) {
|
|
211
|
+
if (!line.trim())
|
|
212
|
+
return null;
|
|
213
|
+
try {
|
|
214
|
+
return asRecord(JSON.parse(line));
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function isCodexMessageRecord(record) {
|
|
221
|
+
return codexRecordRole(record) !== null;
|
|
222
|
+
}
|
|
223
|
+
function codexRecordRole(record) {
|
|
224
|
+
if (stringValue(record.type) !== "event_msg")
|
|
225
|
+
return null;
|
|
226
|
+
const payload = asRecord(record.payload) ?? record;
|
|
227
|
+
const role = stringValue(payload.role);
|
|
228
|
+
if (role === "user" || role === "assistant")
|
|
229
|
+
return role;
|
|
230
|
+
const messageType = stringValue(payload.type) ?? stringValue(payload.kind);
|
|
231
|
+
if (messageType?.includes("user"))
|
|
232
|
+
return "user";
|
|
233
|
+
if (messageType === "task_complete")
|
|
234
|
+
return "assistant";
|
|
235
|
+
if (messageType?.includes("agent") || messageType?.includes("assistant"))
|
|
236
|
+
return "assistant";
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
function isClaudeCodeMessageRecord(record) {
|
|
240
|
+
const role = stringValue(asRecord(record.message)?.role);
|
|
241
|
+
return role === "user" || role === "assistant";
|
|
57
242
|
}
|
|
58
|
-
function
|
|
59
|
-
return
|
|
243
|
+
function isNotFound(error) {
|
|
244
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
60
245
|
}
|
package/dist/sync/slices.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { contentHash
|
|
1
|
+
import { contentHash } from "./hash.js";
|
|
2
2
|
/**
|
|
3
3
|
* Default maximum character count for a content slice.
|
|
4
4
|
*
|
|
@@ -58,17 +58,6 @@ export function takeGraphemes(content, count) {
|
|
|
58
58
|
}
|
|
59
59
|
return result;
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
}));
|
|
61
|
+
export function contentHashesFor(content) {
|
|
62
|
+
return splitContent(content).map((piece) => contentHash(piece));
|
|
74
63
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nessielabs/daemon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Headless Linux ingestion daemon for Nessie agent traces.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@inquirer/prompts": "^7.8.0",
|
|
28
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
29
|
+
"better-sqlite3": "^12.11.1",
|
|
28
30
|
"commander": "^14.0.2"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|
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/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
|
-
}
|