@nessielabs/daemon 0.4.0 → 0.5.27
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 +8 -17
- package/dist/cli.js +42 -21
- package/dist/commands/auth.js +116 -0
- package/dist/commands/integrations.js +84 -0
- package/dist/commands/login.js +167 -0
- package/dist/commands/run.js +21 -6
- package/dist/commands/service.js +18 -36
- package/dist/commands/sharing.js +399 -0
- package/dist/commands/status.js +67 -0
- package/dist/commands/teams.js +64 -0
- package/dist/nera/client.js +156 -0
- package/dist/nera/process.js +78 -0
- package/dist/nera/runtimeFile.js +25 -0
- package/dist/platform/binary.js +21 -0
- package/dist/platform/openBrowser.js +14 -0
- package/dist/platform/paths.js +13 -0
- package/dist/platform/table.js +85 -0
- package/package.json +9 -5
- package/vendor/nera/darwin-arm64/nera +0 -0
- package/vendor/nera/darwin-x64/nera +0 -0
- package/vendor/nera/linux-arm64/nera +0 -0
- package/vendor/nera/linux-x64/nera +0 -0
- package/vendor/nera/win32-arm64/nera.exe +0 -0
- package/vendor/nera/win32-x64/nera.exe +0 -0
- package/vendor/proto/nessie.edge.v1.proto +475 -0
- package/dist/api/http.js +0 -44
- package/dist/auth/deviceFlow.js +0 -36
- package/dist/auth/tokens.js +0 -30
- package/dist/commands/setup.js +0 -75
- package/dist/config/endpoints.js +0 -2
- package/dist/config/paths.js +0 -19
- package/dist/config/store.js +0 -27
- package/dist/git/repoResolver.js +0 -215
- package/dist/localStore/daemonGraphStore.js +0 -494
- package/dist/localStore/messageComparison.js +0 -37
- package/dist/localStore/pushMapping.js +0 -69
- package/dist/localStore/rowTypes.js +0 -1
- package/dist/localStore/schema.js +0 -70
- package/dist/localStore/uuid.js +0 -12
- package/dist/parser/claudeCode.js +0 -202
- package/dist/parser/codex.js +0 -91
- package/dist/parser/jsonl.js +0 -24
- package/dist/parser/types.js +0 -15
- package/dist/redact/secrets.js +0 -60
- package/dist/service/process.js +0 -134
- package/dist/service/systemd.js +0 -38
- package/dist/sources/detect.js +0 -43
- package/dist/sync/batch.js +0 -50
- package/dist/sync/client.js +0 -90
- package/dist/sync/exclusions.js +0 -10
- package/dist/sync/hash.js +0 -4
- package/dist/sync/loop.js +0 -106
- package/dist/sync/scan.js +0 -245
- package/dist/sync/slices.js +0 -63
- package/dist/sync/types.js +0 -1
package/dist/sync/client.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { ApiError, getJson, 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
|
-
const result = await postJsonWithRefresh(config, "/sync/v3/preflight", preflightBody(config), options);
|
|
7
|
-
return result.config;
|
|
8
|
-
}
|
|
9
|
-
export async function runPreflightV4(config, options = {}) {
|
|
10
|
-
const result = await postJsonWithRefresh(config, "/sync/v4/preflight", preflightBody(config), options);
|
|
11
|
-
return result.config;
|
|
12
|
-
}
|
|
13
|
-
export async function listSyncExclusions(config, options = {}) {
|
|
14
|
-
return getJsonWithRefresh(config, "/sync/v4/sync-exclusions", { kind: options.kind }, options);
|
|
15
|
-
}
|
|
16
|
-
export async function pullCloudSyncPage(config, input, options = {}) {
|
|
17
|
-
return postJsonWithRefresh(config, "/sync/pull/v5", {
|
|
18
|
-
deviceId: config.deviceId,
|
|
19
|
-
nodeCursor: input.nodeCursor,
|
|
20
|
-
edgeCursor: input.edgeCursor,
|
|
21
|
-
limit: input.limit,
|
|
22
|
-
}, options);
|
|
23
|
-
}
|
|
24
|
-
function preflightBody(config) {
|
|
25
|
-
return {
|
|
26
|
-
device: {
|
|
27
|
-
id: config.deviceId,
|
|
28
|
-
displayName: config.deviceName,
|
|
29
|
-
},
|
|
30
|
-
resources: config.selectedSources.map((source) => ({
|
|
31
|
-
kind: source.kind,
|
|
32
|
-
localId: source.localId,
|
|
33
|
-
stableKey: source.stableKey,
|
|
34
|
-
displayName: source.displayName,
|
|
35
|
-
})),
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
export async function pushSyncPayload(config, payload, options = {}) {
|
|
39
|
-
const result = await postJsonWithRefresh(config, "/sync/v2/push", {
|
|
40
|
-
deviceId: config.deviceId,
|
|
41
|
-
...payload,
|
|
42
|
-
}, options);
|
|
43
|
-
return {
|
|
44
|
-
config: result.config,
|
|
45
|
-
response: {
|
|
46
|
-
nodes: Array.isArray(result.response.nodes) ? result.response.nodes : [],
|
|
47
|
-
edges: Array.isArray(result.response.edges) ? result.response.edges : [],
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
async function postJsonWithRefresh(config, path, body, options) {
|
|
52
|
-
const token = authToken(config);
|
|
53
|
-
try {
|
|
54
|
-
const response = await postJson(syncApiUrl, path, body, { token, fetchImpl: options.fetchImpl });
|
|
55
|
-
return { config, response };
|
|
56
|
-
}
|
|
57
|
-
catch (error) {
|
|
58
|
-
if (config.apiKey)
|
|
59
|
-
throw error;
|
|
60
|
-
if (!(error instanceof ApiError) || error.status !== 401)
|
|
61
|
-
throw error;
|
|
62
|
-
}
|
|
63
|
-
const refreshed = await refreshAccessToken(config, { fetchImpl: options.fetchImpl });
|
|
64
|
-
await (options.persistConfig ?? writeConfig)(refreshed);
|
|
65
|
-
const response = await postJson(syncApiUrl, path, body, { token: authToken(refreshed), fetchImpl: options.fetchImpl });
|
|
66
|
-
return { config: refreshed, response };
|
|
67
|
-
}
|
|
68
|
-
async function getJsonWithRefresh(config, path, query, options) {
|
|
69
|
-
const token = authToken(config);
|
|
70
|
-
try {
|
|
71
|
-
const response = await getJson(syncApiUrl, path, { token, fetchImpl: options.fetchImpl, query });
|
|
72
|
-
return { config, response };
|
|
73
|
-
}
|
|
74
|
-
catch (error) {
|
|
75
|
-
if (config.apiKey)
|
|
76
|
-
throw error;
|
|
77
|
-
if (!(error instanceof ApiError) || error.status !== 401)
|
|
78
|
-
throw error;
|
|
79
|
-
}
|
|
80
|
-
const refreshed = await refreshAccessToken(config, { fetchImpl: options.fetchImpl });
|
|
81
|
-
await (options.persistConfig ?? writeConfig)(refreshed);
|
|
82
|
-
const response = await getJson(syncApiUrl, path, { token: authToken(refreshed), fetchImpl: options.fetchImpl, query });
|
|
83
|
-
return { config: refreshed, response };
|
|
84
|
-
}
|
|
85
|
-
function authToken(config) {
|
|
86
|
-
const token = config.apiKey ?? config.accessToken;
|
|
87
|
-
if (!token)
|
|
88
|
-
throw new Error("Daemon config is missing auth credentials. Run nessie-daemon setup.");
|
|
89
|
-
return token;
|
|
90
|
-
}
|
package/dist/sync/exclusions.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
export function transcriptKindForSourceKind(kind) {
|
|
2
|
-
return kind === "codex" ? "codex_chat" : "claude_code_chat";
|
|
3
|
-
}
|
|
4
|
-
export function filterExcludedConversations(conversations, exclusions) {
|
|
5
|
-
const excludedIdentities = new Set(exclusions.map((exclusion) => identityKey(exclusion.kind, exclusion.sourceId)));
|
|
6
|
-
return conversations.filter((conversation) => !excludedIdentities.has(identityKey(transcriptKindForSourceKind(conversation.sourceKind), conversation.sourceId)));
|
|
7
|
-
}
|
|
8
|
-
function identityKey(kind, sourceId) {
|
|
9
|
-
return `${kind}\0${sourceId}`;
|
|
10
|
-
}
|
package/dist/sync/hash.js
DELETED
package/dist/sync/loop.js
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
-
import { AuthRefreshError } from "../auth/tokens.js";
|
|
3
|
-
import { getDaemonPaths } from "../config/paths.js";
|
|
4
|
-
import { readConfig } from "../config/store.js";
|
|
5
|
-
import { GitRepoResolver } from "../git/repoResolver.js";
|
|
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";
|
|
15
|
-
export async function runSyncOnce() {
|
|
16
|
-
let config = await readConfig();
|
|
17
|
-
config = await runPreflightV4(config);
|
|
18
|
-
const exclusionsResult = await listSyncExclusions(config);
|
|
19
|
-
config = exclusionsResult.config;
|
|
20
|
-
const exclusions = exclusionsResult.response.exclusions;
|
|
21
|
-
const repoResolver = new GitRepoResolver();
|
|
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 };
|
|
88
|
-
}
|
|
89
|
-
finally {
|
|
90
|
-
store.close();
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
export async function runSyncLoop(intervalSeconds) {
|
|
94
|
-
while (true) {
|
|
95
|
-
try {
|
|
96
|
-
const result = await runSyncOnce();
|
|
97
|
-
console.log(`Synced ${result.nodes} nodes and ${result.edges} edges.`);
|
|
98
|
-
}
|
|
99
|
-
catch (error) {
|
|
100
|
-
console.error(error instanceof Error ? error.message : String(error));
|
|
101
|
-
if (error instanceof AuthRefreshError)
|
|
102
|
-
throw error;
|
|
103
|
-
}
|
|
104
|
-
await sleep(Math.max(5, intervalSeconds) * 1000);
|
|
105
|
-
}
|
|
106
|
-
}
|
package/dist/sync/scan.js
DELETED
|
@@ -1,245 +0,0 @@
|
|
|
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";
|
|
6
|
-
import { parseClaudeCodeSession } from "../parser/claudeCode.js";
|
|
7
|
-
import { parseCodexSession } from "../parser/codex.js";
|
|
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,
|
|
31
|
-
};
|
|
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)
|
|
42
|
-
continue;
|
|
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
|
-
});
|
|
59
|
-
}
|
|
60
|
-
}
|
|
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 };
|
|
133
|
-
}
|
|
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
|
-
}
|
|
175
|
-
}
|
|
176
|
-
async function walkJsonl(root) {
|
|
177
|
-
try {
|
|
178
|
-
const entries = await readdir(root, { withFileTypes: true });
|
|
179
|
-
const nested = await Promise.all(entries.map(async (entry) => {
|
|
180
|
-
const path = join(root, entry.name);
|
|
181
|
-
if (entry.isDirectory())
|
|
182
|
-
return walkJsonl(path);
|
|
183
|
-
return entry.isFile() && entry.name.endsWith(".jsonl") ? [path] : [];
|
|
184
|
-
}));
|
|
185
|
-
return nested.flat().sort();
|
|
186
|
-
}
|
|
187
|
-
catch (error) {
|
|
188
|
-
if (isNotFound(error))
|
|
189
|
-
return [];
|
|
190
|
-
throw error;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
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";
|
|
242
|
-
}
|
|
243
|
-
function isNotFound(error) {
|
|
244
|
-
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
245
|
-
}
|
package/dist/sync/slices.js
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { contentHash } from "./hash.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
|
-
export function contentHashesFor(content) {
|
|
62
|
-
return splitContent(content).map((piece) => contentHash(piece));
|
|
63
|
-
}
|
package/dist/sync/types.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|