@nessielabs/daemon 0.2.2 → 0.3.1
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/config/store.js +5 -0
- package/dist/git/repoResolver.js +215 -0
- package/dist/parser/claudeCode.js +6 -0
- package/dist/parser/codex.js +4 -0
- package/dist/parser/types.js +9 -1
- package/dist/redact/secrets.js +27 -5
- package/dist/sync/batch.js +50 -0
- package/dist/sync/loop.js +25 -3
- package/dist/sync/map.js +15 -10
- package/dist/sync/scan.js +3 -2
- 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.")
|
package/dist/config/store.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
|
+
import { PARSER_VERSION } from "../parser/types.js";
|
|
5
6
|
import { getDaemonPaths } from "./paths.js";
|
|
6
7
|
export function createConfig(input) {
|
|
7
8
|
const deviceId = input.deviceId ?? randomUUID();
|
|
@@ -23,6 +24,7 @@ export function createInitialState() {
|
|
|
23
24
|
files: {},
|
|
24
25
|
pushedSourceRootIds: [],
|
|
25
26
|
pushedSessions: {},
|
|
27
|
+
parserVersion: PARSER_VERSION,
|
|
26
28
|
lastSyncAt: null,
|
|
27
29
|
};
|
|
28
30
|
}
|
|
@@ -40,6 +42,9 @@ export async function readState(paths = getDaemonPaths()) {
|
|
|
40
42
|
files: parsed.files ?? {},
|
|
41
43
|
pushedSourceRootIds: parsed.pushedSourceRootIds ?? [],
|
|
42
44
|
pushedSessions: parsed.pushedSessions ?? {},
|
|
45
|
+
// State written before parser versioning existed predates every
|
|
46
|
+
// versioned parser change, so it always needs the backfill pass.
|
|
47
|
+
parserVersion: parsed.parserVersion ?? 0,
|
|
43
48
|
lastSyncAt: parsed.lastSyncAt ?? null,
|
|
44
49
|
};
|
|
45
50
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
/** Directory levels to walk up looking for `.git` before giving up. */
|
|
5
|
+
const MAX_PARENT_TRAVERSAL = 64;
|
|
6
|
+
/**
|
|
7
|
+
* Resolves the git repository identity for a working directory by reading
|
|
8
|
+
* `.git` metadata from disk, without invoking the `git` binary.
|
|
9
|
+
*
|
|
10
|
+
* Resolution is best-effort by design: paths that are not inside a git
|
|
11
|
+
* repository, repositories without remotes, and repositories deleted since
|
|
12
|
+
* the session simply resolve to null.
|
|
13
|
+
*/
|
|
14
|
+
export class GitRepoResolver {
|
|
15
|
+
/**
|
|
16
|
+
* Memoized results per workspace path: conversations in the same directory
|
|
17
|
+
* repeat heavily within one sync run.
|
|
18
|
+
*/
|
|
19
|
+
cache = new Map();
|
|
20
|
+
resolve(workspacePath) {
|
|
21
|
+
const standardized = resolve(expandTilde(workspacePath));
|
|
22
|
+
const cached = this.cache.get(standardized);
|
|
23
|
+
if (cached !== undefined)
|
|
24
|
+
return cached;
|
|
25
|
+
const resolved = resolveUncached(standardized);
|
|
26
|
+
this.cache.set(standardized, resolved);
|
|
27
|
+
return resolved;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function expandTilde(path) {
|
|
31
|
+
if (path === "~")
|
|
32
|
+
return homedir();
|
|
33
|
+
if (path.startsWith("~/"))
|
|
34
|
+
return join(homedir(), path.slice(2));
|
|
35
|
+
return path;
|
|
36
|
+
}
|
|
37
|
+
function resolveUncached(workspacePath) {
|
|
38
|
+
let directory = workspacePath;
|
|
39
|
+
for (let level = 0; level < MAX_PARENT_TRAVERSAL; level += 1) {
|
|
40
|
+
const configPath = gitConfigPath(join(directory, ".git"));
|
|
41
|
+
if (configPath) {
|
|
42
|
+
const config = readTextFile(configPath);
|
|
43
|
+
const remoteUrl = config !== null ? remoteUrlFromGitConfig(config) : null;
|
|
44
|
+
if (remoteUrl) {
|
|
45
|
+
return { remoteUrl, repoKey: normalizedRepoKey(remoteUrl) };
|
|
46
|
+
}
|
|
47
|
+
// A repo without a remote keeps the walk going so a nested remoteless
|
|
48
|
+
// checkout attributes to its enclosing repo. Matches NessieCore's
|
|
49
|
+
// GitRepoResolver.resolveUncached, the source of truth for repo
|
|
50
|
+
// identity; both implementations must group conversations identically.
|
|
51
|
+
}
|
|
52
|
+
const parent = dirname(directory);
|
|
53
|
+
if (parent === directory)
|
|
54
|
+
return null;
|
|
55
|
+
directory = parent;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Locate the `config` file for a `.git` entry, which is a directory for
|
|
61
|
+
* normal checkouts and a `gitdir:` pointer file for worktrees and submodules.
|
|
62
|
+
*/
|
|
63
|
+
function gitConfigPath(gitEntry) {
|
|
64
|
+
const stats = statSync(gitEntry, { throwIfNoEntry: false });
|
|
65
|
+
if (!stats)
|
|
66
|
+
return null;
|
|
67
|
+
if (stats.isDirectory()) {
|
|
68
|
+
return existingFile(join(gitEntry, "config"));
|
|
69
|
+
}
|
|
70
|
+
const pointer = readTextFile(gitEntry);
|
|
71
|
+
const gitDir = pointer !== null ? gitDirPath(pointer) : null;
|
|
72
|
+
if (!gitDir)
|
|
73
|
+
return null;
|
|
74
|
+
const resolvedGitDir = absolutePath(gitDir, dirname(gitEntry));
|
|
75
|
+
// Worktree gitdirs keep shared state (including `config`) in the main
|
|
76
|
+
// repository's git directory, referenced by a `commondir` file.
|
|
77
|
+
const commonDir = readTextFile(join(resolvedGitDir, "commondir"))?.trim();
|
|
78
|
+
if (commonDir) {
|
|
79
|
+
return existingFile(join(absolutePath(commonDir, resolvedGitDir), "config"));
|
|
80
|
+
}
|
|
81
|
+
return existingFile(join(resolvedGitDir, "config"));
|
|
82
|
+
}
|
|
83
|
+
function existingFile(path) {
|
|
84
|
+
return statSync(path, { throwIfNoEntry: false })?.isFile() ? path : null;
|
|
85
|
+
}
|
|
86
|
+
function readTextFile(path) {
|
|
87
|
+
try {
|
|
88
|
+
return readFileSync(path, "utf8");
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function gitDirPath(pointerContents) {
|
|
95
|
+
for (const line of pointerContents.split(/\r?\n/)) {
|
|
96
|
+
const trimmed = line.trim();
|
|
97
|
+
if (!trimmed.startsWith("gitdir:"))
|
|
98
|
+
continue;
|
|
99
|
+
const path = trimmed.slice("gitdir:".length).trim();
|
|
100
|
+
return path.length > 0 ? path : null;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
function absolutePath(path, base) {
|
|
105
|
+
return isAbsolute(path) ? resolve(path) : resolve(base, path);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Extract a remote URL from raw `.git/config` contents, preferring `origin`
|
|
109
|
+
* and falling back to the first remote with a URL.
|
|
110
|
+
*/
|
|
111
|
+
export function remoteUrlFromGitConfig(contents) {
|
|
112
|
+
let currentRemote = null;
|
|
113
|
+
let originUrl = null;
|
|
114
|
+
let firstUrl = null;
|
|
115
|
+
for (const line of contents.split(/\r?\n/)) {
|
|
116
|
+
const trimmed = line.trim();
|
|
117
|
+
if (trimmed.startsWith("[")) {
|
|
118
|
+
currentRemote = remoteNameFromSectionHeader(trimmed);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!currentRemote)
|
|
122
|
+
continue;
|
|
123
|
+
const url = configValue(trimmed, "url");
|
|
124
|
+
if (!url)
|
|
125
|
+
continue;
|
|
126
|
+
if (currentRemote === "origin" && originUrl === null) {
|
|
127
|
+
originUrl = url;
|
|
128
|
+
}
|
|
129
|
+
if (firstUrl === null) {
|
|
130
|
+
firstUrl = url;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return originUrl ?? firstUrl;
|
|
134
|
+
}
|
|
135
|
+
function remoteNameFromSectionHeader(header) {
|
|
136
|
+
// [remote "origin"]
|
|
137
|
+
if (!header.startsWith("[remote "))
|
|
138
|
+
return null;
|
|
139
|
+
const openQuote = header.indexOf('"');
|
|
140
|
+
const closeQuote = header.lastIndexOf('"');
|
|
141
|
+
if (openQuote === -1 || closeQuote <= openQuote)
|
|
142
|
+
return null;
|
|
143
|
+
return header.slice(openQuote + 1, closeQuote);
|
|
144
|
+
}
|
|
145
|
+
function configValue(line, key) {
|
|
146
|
+
const equals = line.indexOf("=");
|
|
147
|
+
if (equals === -1)
|
|
148
|
+
return null;
|
|
149
|
+
if (line.slice(0, equals).trim() !== key)
|
|
150
|
+
return null;
|
|
151
|
+
const value = line.slice(equals + 1).trim();
|
|
152
|
+
return value.length > 0 ? value : null;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Normalize a configured remote URL to a stable `host/path` identity so the
|
|
156
|
+
* same repository matches across protocols, credentials, machines, and
|
|
157
|
+
* clones: `git@github.com:Org/Repo.git` and `https://github.com/org/repo`
|
|
158
|
+
* both become `github.com/org/repo`.
|
|
159
|
+
*/
|
|
160
|
+
export function normalizedRepoKey(remoteUrl) {
|
|
161
|
+
let rest = remoteUrl.trim();
|
|
162
|
+
if (rest.length === 0)
|
|
163
|
+
return null;
|
|
164
|
+
const schemeEnd = rest.indexOf("://");
|
|
165
|
+
if (schemeEnd !== -1) {
|
|
166
|
+
const scheme = rest.slice(0, schemeEnd).toLowerCase();
|
|
167
|
+
// Local remotes have no cross-machine identity.
|
|
168
|
+
if (scheme === "file")
|
|
169
|
+
return null;
|
|
170
|
+
rest = rest.slice(schemeEnd + "://".length);
|
|
171
|
+
}
|
|
172
|
+
else if (rest.includes(":") && (rest.includes("@") || !rest.startsWith("/"))) {
|
|
173
|
+
// scp-like syntax: [user@]host:path
|
|
174
|
+
const colon = rest.indexOf(":");
|
|
175
|
+
const hostPart = rest.slice(0, colon);
|
|
176
|
+
const path = rest.slice(colon + 1);
|
|
177
|
+
if (hostPart.length === 0 || path.length === 0 || hostPart.includes("/"))
|
|
178
|
+
return null;
|
|
179
|
+
rest = `${hostPart}/${path}`;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// Bare filesystem path remote.
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
// Strip userinfo.
|
|
186
|
+
const lastAt = rest.lastIndexOf("@");
|
|
187
|
+
const firstSlash = rest.indexOf("/");
|
|
188
|
+
if (lastAt !== -1 && firstSlash !== -1 && lastAt < firstSlash) {
|
|
189
|
+
rest = rest.slice(lastAt + 1);
|
|
190
|
+
}
|
|
191
|
+
else if (rest.includes("@") && !rest.includes("/")) {
|
|
192
|
+
rest = rest.slice(rest.indexOf("@") + 1);
|
|
193
|
+
}
|
|
194
|
+
const components = rest.split("/").filter((component) => component.length > 0);
|
|
195
|
+
if (components.length < 2)
|
|
196
|
+
return null;
|
|
197
|
+
// Lowercase the host and drop any port; path case is preserved except for
|
|
198
|
+
// trailing `.git`.
|
|
199
|
+
let host = components[0].toLowerCase();
|
|
200
|
+
const portColon = host.indexOf(":");
|
|
201
|
+
if (portColon !== -1) {
|
|
202
|
+
host = host.slice(0, portColon);
|
|
203
|
+
}
|
|
204
|
+
if (!host.includes(".") && host !== "localhost")
|
|
205
|
+
return null;
|
|
206
|
+
components[0] = host;
|
|
207
|
+
let last = components[components.length - 1];
|
|
208
|
+
if (last.toLowerCase().endsWith(".git")) {
|
|
209
|
+
last = last.slice(0, -4);
|
|
210
|
+
}
|
|
211
|
+
if (last.length === 0)
|
|
212
|
+
return null;
|
|
213
|
+
components[components.length - 1] = last;
|
|
214
|
+
return components.join("/");
|
|
215
|
+
}
|
|
@@ -8,10 +8,15 @@ export async function parseClaudeCodeSession(path) {
|
|
|
8
8
|
let title = null;
|
|
9
9
|
let createdAt = null;
|
|
10
10
|
let updatedAt = null;
|
|
11
|
+
let cwd = null;
|
|
11
12
|
for (const value of records) {
|
|
12
13
|
const record = asRecord(value);
|
|
13
14
|
if (!record)
|
|
14
15
|
continue;
|
|
16
|
+
// Every transcript line stamps cwd, including sidechain lines, so capture
|
|
17
|
+
// it before any filtering. The path-encoded project directory name is
|
|
18
|
+
// lossy (dashes vs. path separators), so this is the only reliable source.
|
|
19
|
+
cwd ??= stringValue(record.cwd);
|
|
15
20
|
if (record.isSidechain === true)
|
|
16
21
|
continue;
|
|
17
22
|
sessionId = stringValue(record.sessionId) ?? stringValue(record.session_id) ?? sessionId;
|
|
@@ -40,6 +45,7 @@ export async function parseClaudeCodeSession(path) {
|
|
|
40
45
|
title: title ?? "Claude Code conversation",
|
|
41
46
|
createdAt: createdAt ?? now,
|
|
42
47
|
updatedAt: updatedAt ?? createdAt ?? now,
|
|
48
|
+
cwd,
|
|
43
49
|
messages,
|
|
44
50
|
};
|
|
45
51
|
}
|
package/dist/parser/codex.js
CHANGED
|
@@ -8,6 +8,7 @@ export async function parseCodexSession(path) {
|
|
|
8
8
|
let title = null;
|
|
9
9
|
let createdAt = null;
|
|
10
10
|
let updatedAt = null;
|
|
11
|
+
let cwd = null;
|
|
11
12
|
for (const value of records) {
|
|
12
13
|
const record = asRecord(value);
|
|
13
14
|
if (!record)
|
|
@@ -21,6 +22,8 @@ export async function parseCodexSession(path) {
|
|
|
21
22
|
if (type === "session_meta") {
|
|
22
23
|
sessionId = stringValue(record.id) ?? stringValue(record.session_id) ?? sessionId;
|
|
23
24
|
title = redactTitle(stringValue(record.title)) ?? title;
|
|
25
|
+
const meta = asRecord(record.payload) ?? record;
|
|
26
|
+
cwd ??= stringValue(meta.cwd);
|
|
24
27
|
continue;
|
|
25
28
|
}
|
|
26
29
|
if (type !== "event_msg")
|
|
@@ -45,6 +48,7 @@ export async function parseCodexSession(path) {
|
|
|
45
48
|
title: title ?? "Codex conversation",
|
|
46
49
|
createdAt: createdAt ?? now,
|
|
47
50
|
updatedAt: updatedAt ?? createdAt ?? now,
|
|
51
|
+
cwd,
|
|
48
52
|
messages,
|
|
49
53
|
};
|
|
50
54
|
}
|
package/dist/parser/types.js
CHANGED
|
@@ -1 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Version of the daemon's transcript parsing + enrichment semantics. Bumping
|
|
3
|
+
* it makes the next sync reparse every session file and re-push conversation
|
|
4
|
+
* headers as field-masked merges, so previously synced conversations pick up
|
|
5
|
+
* newly extracted metadata. Messages are append-only and are never re-pushed.
|
|
6
|
+
*
|
|
7
|
+
* 1: workspace/repo metadata (cwd, git remote, repo key) on conversations.
|
|
8
|
+
*/
|
|
9
|
+
export const PARSER_VERSION = 1;
|
package/dist/redact/secrets.js
CHANGED
|
@@ -2,6 +2,8 @@ export const secretReplacement = "[REDACTED]";
|
|
|
2
2
|
const literalIndicators = [
|
|
3
3
|
"ghp_",
|
|
4
4
|
"github_pat_",
|
|
5
|
+
"napi_",
|
|
6
|
+
"npg_",
|
|
5
7
|
"sk-",
|
|
6
8
|
];
|
|
7
9
|
const caseInsensitiveIndicators = [
|
|
@@ -13,22 +15,42 @@ const caseInsensitiveIndicators = [
|
|
|
13
15
|
"token=",
|
|
14
16
|
"key=",
|
|
15
17
|
"api_key=",
|
|
18
|
+
"connection_string=",
|
|
19
|
+
"database_url=",
|
|
20
|
+
"db_url=",
|
|
16
21
|
"password=",
|
|
22
|
+
"postgres_url=",
|
|
23
|
+
"postgresql_url=",
|
|
24
|
+
"postgres://",
|
|
25
|
+
"postgresql://",
|
|
17
26
|
"secret=",
|
|
27
|
+
"supabase",
|
|
18
28
|
];
|
|
19
29
|
const fullReplacementPatterns = [
|
|
20
30
|
/\bghp_[A-Za-z0-9_]{20,}\b/g,
|
|
21
31
|
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
32
|
+
/\bnapi_[A-Za-z0-9_-]{16,}\b/g,
|
|
33
|
+
/\bnpg_[A-Za-z0-9_-]{16,}\b/g,
|
|
22
34
|
/\bsk-[A-Za-z0-9_-]{16,}\b/g,
|
|
35
|
+
/\bsb_(?:publishable|secret)_[A-Za-z0-9_-]{16,}\b/g,
|
|
36
|
+
/\bsbp_[A-Za-z0-9_-]{16,}\b/g,
|
|
37
|
+
/\bpostgres(?:ql)?:\/\/[^\s'"`<>]+/gi,
|
|
23
38
|
/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi,
|
|
24
39
|
/-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/gi,
|
|
25
40
|
];
|
|
26
|
-
const
|
|
41
|
+
const uuidPattern = /\b[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b/gi;
|
|
42
|
+
const sensitiveKeyNamePattern = String.raw `[A-Za-z0-9_]*(?:token|api[_-]?key|apikey|password|secret|[_-]key|connection[_-]?string|database[_-]?url|db[_-]?url|postgres(?:ql)?[_-]?url)|key`;
|
|
43
|
+
const unquotedAssignmentValuePattern = "[^\\s&;,'\"`}\\[\\]]+";
|
|
44
|
+
const assignmentPattern = new RegExp(String.raw `\b(${sensitiveKeyNamePattern})\b(\s*=\s*)(["']?)(${unquotedAssignmentValuePattern})\3?`, "gi");
|
|
45
|
+
const quotedAssignmentPattern = new RegExp(String.raw `(["'])(${sensitiveKeyNamePattern})\1(\s*:\s*)(["'])([^"']+)\4`, "gi");
|
|
27
46
|
export function redactSecrets(text) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
const withoutUuids = text.replace(uuidPattern, secretReplacement);
|
|
48
|
+
if (!mightContainSecret(withoutUuids))
|
|
49
|
+
return withoutUuids;
|
|
50
|
+
const withoutWholeSecrets = fullReplacementPatterns.reduce((result, pattern) => result.replace(pattern, secretReplacement), withoutUuids);
|
|
51
|
+
return withoutWholeSecrets
|
|
52
|
+
.replace(quotedAssignmentPattern, (_match, keyQuote, key, separator, valueQuote) => `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${secretReplacement}${valueQuote}`)
|
|
53
|
+
.replace(assignmentPattern, (_match, key, separator, valueQuote) => `${key}${separator}${valueQuote}${secretReplacement}${valueQuote}`);
|
|
32
54
|
}
|
|
33
55
|
export function mightContainSecret(text) {
|
|
34
56
|
if (literalIndicators.some((indicator) => text.includes(indicator)))
|
|
@@ -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
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
2
|
import { AuthRefreshError } from "../auth/tokens.js";
|
|
3
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";
|
|
4
7
|
import { runPreflightV3, pushSyncPayload } from "./client.js";
|
|
5
8
|
import { mapSessionsToPushItems } from "./map.js";
|
|
6
9
|
import { scanSelectedSources } from "./scan.js";
|
|
@@ -8,7 +11,15 @@ export async function runSyncOnce() {
|
|
|
8
11
|
let config = await readConfig();
|
|
9
12
|
const state = await readState();
|
|
10
13
|
config = await runPreflightV3(config);
|
|
11
|
-
|
|
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();
|
|
12
23
|
const nodes = [];
|
|
13
24
|
const edges = [];
|
|
14
25
|
const previouslyPushedRoots = new Set(state.pushedSourceRootIds);
|
|
@@ -20,6 +31,8 @@ export async function runSyncOnce() {
|
|
|
20
31
|
const mapped = mapSessionsToPushItems(source, sessions, {
|
|
21
32
|
includeRoot,
|
|
22
33
|
pushedSessions: state.pushedSessions,
|
|
34
|
+
repushHeaders: backfillParserChanges,
|
|
35
|
+
resolveRepo: (workspacePath) => repoResolver.resolve(workspacePath),
|
|
23
36
|
});
|
|
24
37
|
nodes.push(...mapped.nodes);
|
|
25
38
|
edges.push(...mapped.edges);
|
|
@@ -27,13 +40,22 @@ export async function runSyncOnce() {
|
|
|
27
40
|
pushedSourceRootIds.add(source.localId);
|
|
28
41
|
Object.assign(pushedSessions, mapped.pushedSessions);
|
|
29
42
|
}
|
|
30
|
-
|
|
31
|
-
|
|
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);
|
|
32
53
|
}
|
|
33
54
|
await writeState({
|
|
34
55
|
...scan.nextState,
|
|
35
56
|
pushedSourceRootIds: [...pushedSourceRootIds].sort(),
|
|
36
57
|
pushedSessions,
|
|
58
|
+
parserVersion: PARSER_VERSION,
|
|
37
59
|
lastSyncAt: new Date().toISOString(),
|
|
38
60
|
});
|
|
39
61
|
return { nodes: nodes.length, edges: edges.length };
|
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 = {};
|
|
@@ -12,7 +13,11 @@ export function mapSessionsToPushItems(source, sessions, options = { includeRoot
|
|
|
12
13
|
conversationPushed: false,
|
|
13
14
|
pushedMessageCount: 0,
|
|
14
15
|
};
|
|
15
|
-
if (!sessionCursor.conversationPushed) {
|
|
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).
|
|
16
21
|
nodes.push({
|
|
17
22
|
node: {
|
|
18
23
|
id: conversationId,
|
|
@@ -20,6 +25,9 @@ export function mapSessionsToPushItems(source, sessions, options = { includeRoot
|
|
|
20
25
|
sourceId: session.sourceId,
|
|
21
26
|
name: redactSecrets(session.title),
|
|
22
27
|
kind: session.sourceKind === "codex" ? "codex_chat" : "claude_code_chat",
|
|
28
|
+
workspacePath: session.cwd,
|
|
29
|
+
repoRemoteUrl: repoInfo?.remoteUrl ?? null,
|
|
30
|
+
repoKey: repoInfo?.repoKey ?? null,
|
|
23
31
|
originalCreatedAt: session.createdAt,
|
|
24
32
|
originalUpdatedAt: session.updatedAt,
|
|
25
33
|
},
|
|
@@ -27,7 +35,10 @@ export function mapSessionsToPushItems(source, sessions, options = { includeRoot
|
|
|
27
35
|
slices: [],
|
|
28
36
|
aiChatMessage: null,
|
|
29
37
|
nessieChatMessage: null,
|
|
38
|
+
mergeFields: ["workspacePath", "repoRemoteUrl", "repoKey"],
|
|
30
39
|
});
|
|
40
|
+
}
|
|
41
|
+
if (!sessionCursor.conversationPushed) {
|
|
31
42
|
edges.push(mapEdge(source.localId, conversationId, "source_of", 0));
|
|
32
43
|
}
|
|
33
44
|
const pushableMessages = session.messages.filter((message) => message.role !== "system");
|
|
@@ -45,19 +56,13 @@ export function mapSessionsToPushItems(source, sessions, options = { includeRoot
|
|
|
45
56
|
id: messageId,
|
|
46
57
|
integrationId: source.localId,
|
|
47
58
|
sourceId: `${session.sourceId}#${index}`,
|
|
48
|
-
name: content
|
|
59
|
+
name: takeGraphemes(content, 80) || message.role,
|
|
49
60
|
kind: session.sourceKind === "codex" ? "codex_chat_message" : "claude_code_chat_message",
|
|
50
61
|
originalCreatedAt: timestamp,
|
|
51
62
|
originalUpdatedAt: timestamp,
|
|
52
63
|
},
|
|
53
64
|
baseCloudUpdatedAt: null,
|
|
54
|
-
slices:
|
|
55
|
-
id: stableUuidFromString(`${messageId}:slice:0`),
|
|
56
|
-
nodeId: messageId,
|
|
57
|
-
index: 0,
|
|
58
|
-
content,
|
|
59
|
-
contentHash: contentHash(content),
|
|
60
|
-
}],
|
|
65
|
+
slices: buildContentSlices(messageId, content),
|
|
61
66
|
aiChatMessage: {
|
|
62
67
|
nodeId: messageId,
|
|
63
68
|
role: message.role,
|
package/dist/sync/scan.js
CHANGED
|
@@ -2,11 +2,12 @@ import { readdir, stat } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parseClaudeCodeSession } from "../parser/claudeCode.js";
|
|
4
4
|
import { parseCodexSession } from "../parser/codex.js";
|
|
5
|
-
export async function scanSelectedSources(sources, state) {
|
|
5
|
+
export async function scanSelectedSources(sources, state, options = {}) {
|
|
6
6
|
const nextState = {
|
|
7
7
|
files: { ...state.files },
|
|
8
8
|
pushedSourceRootIds: [...state.pushedSourceRootIds],
|
|
9
9
|
pushedSessions: { ...state.pushedSessions },
|
|
10
|
+
parserVersion: state.parserVersion,
|
|
10
11
|
lastSyncAt: state.lastSyncAt,
|
|
11
12
|
};
|
|
12
13
|
const sessionsBySourceId = new Map();
|
|
@@ -15,7 +16,7 @@ export async function scanSelectedSources(sources, state) {
|
|
|
15
16
|
const sessions = [];
|
|
16
17
|
for (const file of files) {
|
|
17
18
|
const cursor = await cursorForFile(file);
|
|
18
|
-
if (isUnchanged(state.files[file], cursor))
|
|
19
|
+
if (options.forceReparse !== true && isUnchanged(state.files[file], cursor))
|
|
19
20
|
continue;
|
|
20
21
|
const session = source.kind === "codex" ? await parseCodexSession(file) : await parseClaudeCodeSession(file);
|
|
21
22
|
nextState.files[file] = cursor;
|
|
@@ -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
|
+
}
|