@nothumanwork/nn 0.1.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/LICENSE +21 -0
- package/README.md +77 -0
- package/bin/nn.js +106 -0
- package/package.json +74 -0
- package/src/config/env.ts +31 -0
- package/src/config/paths.ts +50 -0
- package/src/config/runtime.ts +37 -0
- package/src/config/sync.ts +48 -0
- package/src/db/client.ts +333 -0
- package/src/db/libsql-native.ts +66 -0
- package/src/db/lock.ts +72 -0
- package/src/db/migrate.ts +246 -0
- package/src/db/replica-migrate.ts +162 -0
- package/src/db/schema.sql +99 -0
- package/src/export/claude.ts +92 -0
- package/src/export/codex.ts +86 -0
- package/src/export/cursor.ts +68 -0
- package/src/export/generic.ts +19 -0
- package/src/export/registry.ts +118 -0
- package/src/export/types.ts +44 -0
- package/src/hooks/ingest.ts +107 -0
- package/src/hooks/resolvers/antigravity.ts +44 -0
- package/src/hooks/resolvers/claude.ts +27 -0
- package/src/hooks/resolvers/codex.ts +65 -0
- package/src/hooks/resolvers/common.ts +21 -0
- package/src/hooks/resolvers/cursor.ts +31 -0
- package/src/hooks/resolvers/grok.ts +59 -0
- package/src/hooks/resolvers/index.ts +35 -0
- package/src/hooks/resolvers/pi.ts +72 -0
- package/src/hooks/types.ts +20 -0
- package/src/index.ts +247 -0
- package/src/ingest/jsonl.ts +38 -0
- package/src/ingest/pipeline.ts +101 -0
- package/src/install/index.ts +227 -0
- package/src/install/types.ts +85 -0
- package/src/ir/event-id.ts +26 -0
- package/src/ir/types.ts +84 -0
- package/src/providers/antigravity/index.ts +175 -0
- package/src/providers/claude/index.ts +228 -0
- package/src/providers/codex/index.ts +264 -0
- package/src/providers/copilot/index.ts +24 -0
- package/src/providers/cursor/index.ts +340 -0
- package/src/providers/grok/index.ts +146 -0
- package/src/providers/pi/index.ts +197 -0
- package/src/providers/registry.ts +31 -0
- package/src/providers/types.ts +53 -0
- package/src/sync/coordinator.ts +186 -0
- package/src/sync/turso.ts +64 -0
- package/src/types/assets.d.ts +4 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { homeDir, projectDir } from "../../config/paths.ts";
|
|
5
|
+
import { lineKeyFor, parseCompleteJsonlLines } from "../../ingest/jsonl.ts";
|
|
6
|
+
import type {
|
|
7
|
+
EventBoundary,
|
|
8
|
+
NormalizedEvent,
|
|
9
|
+
SourceImportBatch,
|
|
10
|
+
SourceSessionRef,
|
|
11
|
+
} from "../../ir/types.ts";
|
|
12
|
+
import { defaultRole } from "../../ir/types.ts";
|
|
13
|
+
import type { ProviderAdapter } from "../types.ts";
|
|
14
|
+
import { stringField, textFromUnknown, truncate } from "../types.ts";
|
|
15
|
+
|
|
16
|
+
export const PARSER_VERSION = "grok-updates-jsonl-v1";
|
|
17
|
+
|
|
18
|
+
function grokRoot(): string {
|
|
19
|
+
return join(homeDir(), ".grok", "sessions");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function discoverGrokSessions(): SourceSessionRef[] {
|
|
23
|
+
if (!existsSync(grokRoot())) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const sessions: SourceSessionRef[] = [];
|
|
27
|
+
for (const cwdEntry of readdirSync(grokRoot(), { withFileTypes: true })) {
|
|
28
|
+
if (!cwdEntry.isDirectory()) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const cwdPath = join(grokRoot(), cwdEntry.name);
|
|
32
|
+
for (const sessionEntry of readdirSync(cwdPath, { withFileTypes: true })) {
|
|
33
|
+
if (!sessionEntry.isDirectory()) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const updatesPath = join(cwdPath, sessionEntry.name, "updates.jsonl");
|
|
37
|
+
if (!existsSync(updatesPath)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
sessions.push({
|
|
41
|
+
source: "grok",
|
|
42
|
+
sessionId: sessionEntry.name,
|
|
43
|
+
path: updatesPath,
|
|
44
|
+
workspaceRoot: projectDir(),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return sessions;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function boundaryForGrokUpdate(updateType: string): EventBoundary {
|
|
52
|
+
if (updateType === "user_message_chunk") {
|
|
53
|
+
return "user_turn";
|
|
54
|
+
}
|
|
55
|
+
if (updateType === "agent_message_chunk") {
|
|
56
|
+
return "assistant_turn";
|
|
57
|
+
}
|
|
58
|
+
return "unknown_raw_event";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseGrokFile(
|
|
62
|
+
sourcePath: string,
|
|
63
|
+
sessionId: string,
|
|
64
|
+
offset: number,
|
|
65
|
+
): SourceImportBatch {
|
|
66
|
+
const buffer = readFileSync(sourcePath);
|
|
67
|
+
const slice = buffer.subarray(offset);
|
|
68
|
+
const { lines, consumedBytes, partialTail } = parseCompleteJsonlLines(
|
|
69
|
+
slice,
|
|
70
|
+
0,
|
|
71
|
+
);
|
|
72
|
+
const events: NormalizedEvent[] = [];
|
|
73
|
+
const warnings: string[] = [];
|
|
74
|
+
let emittedLines = 0;
|
|
75
|
+
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
emittedLines = line.lineIndex;
|
|
78
|
+
const absoluteStart = offset + line.lineStart;
|
|
79
|
+
const lineKey = lineKeyFor(sourcePath, absoluteStart);
|
|
80
|
+
let value: Record<string, unknown>;
|
|
81
|
+
try {
|
|
82
|
+
value = JSON.parse(line.raw) as Record<string, unknown>;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
warnings.push(
|
|
85
|
+
`${sourcePath}:${line.lineIndex} malformed row: ${String(error)}`,
|
|
86
|
+
);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const params = value.params as Record<string, unknown> | undefined;
|
|
91
|
+
const update = params?.update as Record<string, unknown> | undefined;
|
|
92
|
+
const updateType = stringField(update, "sessionUpdate") ?? "unknown";
|
|
93
|
+
const text = textFromUnknown(update?.content ?? update) ?? "";
|
|
94
|
+
if (!text.trim()) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const boundary = boundaryForGrokUpdate(updateType);
|
|
98
|
+
events.push({
|
|
99
|
+
source: "grok",
|
|
100
|
+
sourceSessionId: stringField(params, "sessionId") ?? sessionId,
|
|
101
|
+
sourceEventId: `line:${line.lineIndex}`,
|
|
102
|
+
lineKey,
|
|
103
|
+
boundary,
|
|
104
|
+
role:
|
|
105
|
+
boundary === "user_turn" ? "user"
|
|
106
|
+
: boundary === "assistant_turn" ? "assistant"
|
|
107
|
+
: defaultRole(boundary),
|
|
108
|
+
timestamp: new Date(
|
|
109
|
+
Number(value.timestamp ?? Date.now()) * 1000,
|
|
110
|
+
).toISOString(),
|
|
111
|
+
contentText: truncate(text),
|
|
112
|
+
parserVersion: PARSER_VERSION,
|
|
113
|
+
rawLocalRef: `${sourcePath}:${line.lineIndex}`,
|
|
114
|
+
rawJson: line.raw,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const absoluteConsumed =
|
|
119
|
+
partialTail ?
|
|
120
|
+
offset + (lines.at(-1)?.lineEnd ?? 0)
|
|
121
|
+
: offset + consumedBytes;
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
source: "grok",
|
|
125
|
+
parserVersion: PARSER_VERSION,
|
|
126
|
+
session: {
|
|
127
|
+
source: "grok",
|
|
128
|
+
sessionId,
|
|
129
|
+
path: sourcePath,
|
|
130
|
+
workspaceRoot: projectDir(),
|
|
131
|
+
},
|
|
132
|
+
events,
|
|
133
|
+
cursorKey: sourcePath,
|
|
134
|
+
consumedBytes: absoluteConsumed,
|
|
135
|
+
emittedLines,
|
|
136
|
+
warnings,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const grokProvider: ProviderAdapter = {
|
|
141
|
+
name: "grok",
|
|
142
|
+
parserVersion: PARSER_VERSION,
|
|
143
|
+
discover: discoverGrokSessions,
|
|
144
|
+
parseIncremental: parseGrokFile,
|
|
145
|
+
parseFull: (sourcePath, sessionId) => parseGrokFile(sourcePath, sessionId, 0),
|
|
146
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { homeDir, projectDir } from "../../config/paths.ts";
|
|
5
|
+
import { lineKeyFor, parseCompleteJsonlLines } from "../../ingest/jsonl.ts";
|
|
6
|
+
import type {
|
|
7
|
+
EventBoundary,
|
|
8
|
+
NormalizedEvent,
|
|
9
|
+
SourceImportBatch,
|
|
10
|
+
SourceSessionRef,
|
|
11
|
+
} from "../../ir/types.ts";
|
|
12
|
+
import type { ProviderAdapter } from "../types.ts";
|
|
13
|
+
import { stringField, textFromUnknown, truncate } from "../types.ts";
|
|
14
|
+
|
|
15
|
+
export const PARSER_VERSION = "pi-session-jsonl-v3";
|
|
16
|
+
|
|
17
|
+
function piRoot(): string {
|
|
18
|
+
return join(homeDir(), ".pi", "agent", "sessions");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function discoverPiSessions(): SourceSessionRef[] {
|
|
22
|
+
if (!existsSync(piRoot())) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const sessions: SourceSessionRef[] = [];
|
|
26
|
+
const walk = (dir: string): void => {
|
|
27
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
28
|
+
const path = join(dir, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
walk(path);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
34
|
+
const parts = entry.name.replace(".jsonl", "").split("_");
|
|
35
|
+
const sessionId = parts.at(-1) ?? basename(entry.name, ".jsonl");
|
|
36
|
+
sessions.push({
|
|
37
|
+
source: "pi",
|
|
38
|
+
sessionId,
|
|
39
|
+
path,
|
|
40
|
+
workspaceRoot: projectDir(),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
walk(piRoot());
|
|
46
|
+
return sessions;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function boundaryForPiMessage(role: string, blockType?: string): EventBoundary {
|
|
50
|
+
if (role === "user") {
|
|
51
|
+
return "user_turn";
|
|
52
|
+
}
|
|
53
|
+
if (role === "assistant") {
|
|
54
|
+
return blockType === "toolCall" ? "tool_call" : "assistant_turn";
|
|
55
|
+
}
|
|
56
|
+
if (role === "toolResult") {
|
|
57
|
+
return "tool_result";
|
|
58
|
+
}
|
|
59
|
+
return "unknown_raw_event";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parsePiFile(
|
|
63
|
+
sourcePath: string,
|
|
64
|
+
sessionId: string,
|
|
65
|
+
offset: number,
|
|
66
|
+
): SourceImportBatch {
|
|
67
|
+
const buffer = readFileSync(sourcePath);
|
|
68
|
+
const slice = buffer.subarray(offset);
|
|
69
|
+
const { lines, consumedBytes, partialTail } = parseCompleteJsonlLines(
|
|
70
|
+
slice,
|
|
71
|
+
0,
|
|
72
|
+
);
|
|
73
|
+
const events: NormalizedEvent[] = [];
|
|
74
|
+
const warnings: string[] = [];
|
|
75
|
+
let currentSessionId = sessionId;
|
|
76
|
+
let emittedLines = 0;
|
|
77
|
+
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
emittedLines = line.lineIndex;
|
|
80
|
+
const absoluteStart = offset + line.lineStart;
|
|
81
|
+
const lineKey = lineKeyFor(sourcePath, absoluteStart);
|
|
82
|
+
let value: Record<string, unknown>;
|
|
83
|
+
try {
|
|
84
|
+
value = JSON.parse(line.raw) as Record<string, unknown>;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
warnings.push(
|
|
87
|
+
`${sourcePath}:${line.lineIndex} malformed row: ${String(error)}`,
|
|
88
|
+
);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rowType = stringField(value, "type") ?? "unknown";
|
|
93
|
+
if (rowType === "session") {
|
|
94
|
+
currentSessionId = stringField(value, "id") ?? currentSessionId;
|
|
95
|
+
events.push({
|
|
96
|
+
source: "pi",
|
|
97
|
+
sourceSessionId: currentSessionId,
|
|
98
|
+
sourceEventId: currentSessionId,
|
|
99
|
+
lineKey,
|
|
100
|
+
boundary: "session_start",
|
|
101
|
+
role: "system",
|
|
102
|
+
timestamp: stringField(value, "timestamp") ?? new Date().toISOString(),
|
|
103
|
+
contentText: "Pi session started",
|
|
104
|
+
parserVersion: PARSER_VERSION,
|
|
105
|
+
rawLocalRef: `${sourcePath}:${line.lineIndex}`,
|
|
106
|
+
rawJson: line.raw,
|
|
107
|
+
});
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (rowType !== "message") {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const message = value.message as Record<string, unknown> | undefined;
|
|
116
|
+
const role = stringField(message, "role") ?? "unknown";
|
|
117
|
+
const timestamp =
|
|
118
|
+
stringField(value, "timestamp") ?? new Date().toISOString();
|
|
119
|
+
const rowEventId = stringField(value, "id") ?? `line:${line.lineIndex}`;
|
|
120
|
+
const content = message?.content;
|
|
121
|
+
|
|
122
|
+
if (Array.isArray(content)) {
|
|
123
|
+
for (const [index, block] of content.entries()) {
|
|
124
|
+
const item = block as Record<string, unknown>;
|
|
125
|
+
const blockType = stringField(item, "type");
|
|
126
|
+
const text = textFromUnknown(item) ?? "";
|
|
127
|
+
events.push({
|
|
128
|
+
source: "pi",
|
|
129
|
+
sourceSessionId: currentSessionId,
|
|
130
|
+
sourceEventId: `${rowEventId}:block:${index}`,
|
|
131
|
+
lineKey: `${lineKey}:block:${index}`,
|
|
132
|
+
boundary: boundaryForPiMessage(role, blockType),
|
|
133
|
+
role: role === "toolResult" ? "tool" : role,
|
|
134
|
+
timestamp,
|
|
135
|
+
contentText: truncate(text || JSON.stringify(item)),
|
|
136
|
+
toolName: stringField(item, "name"),
|
|
137
|
+
toolCallId:
|
|
138
|
+
stringField(item, "id") ?? stringField(message, "toolCallId"),
|
|
139
|
+
toolInputJson:
|
|
140
|
+
blockType === "toolCall" ?
|
|
141
|
+
JSON.stringify(item.arguments ?? {})
|
|
142
|
+
: undefined,
|
|
143
|
+
toolOutputJson:
|
|
144
|
+
role === "toolResult" ? JSON.stringify(item) : undefined,
|
|
145
|
+
parserVersion: PARSER_VERSION,
|
|
146
|
+
rawLocalRef: `${sourcePath}:${line.lineIndex}`,
|
|
147
|
+
rawJson: line.raw,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const text = textFromUnknown(message) ?? truncate(JSON.stringify(value));
|
|
154
|
+
events.push({
|
|
155
|
+
source: "pi",
|
|
156
|
+
sourceSessionId: currentSessionId,
|
|
157
|
+
sourceEventId: rowEventId,
|
|
158
|
+
lineKey,
|
|
159
|
+
boundary: boundaryForPiMessage(role),
|
|
160
|
+
role: role === "toolResult" ? "tool" : role,
|
|
161
|
+
timestamp,
|
|
162
|
+
contentText: text,
|
|
163
|
+
parserVersion: PARSER_VERSION,
|
|
164
|
+
rawLocalRef: `${sourcePath}:${line.lineIndex}`,
|
|
165
|
+
rawJson: line.raw,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const absoluteConsumed =
|
|
170
|
+
partialTail ?
|
|
171
|
+
offset + (lines.at(-1)?.lineEnd ?? 0)
|
|
172
|
+
: offset + consumedBytes;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
source: "pi",
|
|
176
|
+
parserVersion: PARSER_VERSION,
|
|
177
|
+
session: {
|
|
178
|
+
source: "pi",
|
|
179
|
+
sessionId: currentSessionId,
|
|
180
|
+
path: sourcePath,
|
|
181
|
+
workspaceRoot: projectDir(),
|
|
182
|
+
},
|
|
183
|
+
events,
|
|
184
|
+
cursorKey: sourcePath,
|
|
185
|
+
consumedBytes: absoluteConsumed,
|
|
186
|
+
emittedLines,
|
|
187
|
+
warnings,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const piProvider: ProviderAdapter = {
|
|
192
|
+
name: "pi",
|
|
193
|
+
parserVersion: PARSER_VERSION,
|
|
194
|
+
discover: discoverPiSessions,
|
|
195
|
+
parseIncremental: parsePiFile,
|
|
196
|
+
parseFull: (sourcePath, sessionId) => parsePiFile(sourcePath, sessionId, 0),
|
|
197
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { cursorProvider } from "./cursor/index.ts";
|
|
2
|
+
import { claudeProvider } from "./claude/index.ts";
|
|
3
|
+
import { codexProvider } from "./codex/index.ts";
|
|
4
|
+
import { copilotProvider } from "./copilot/index.ts";
|
|
5
|
+
import { antigravityProvider } from "./antigravity/index.ts";
|
|
6
|
+
import { piProvider } from "./pi/index.ts";
|
|
7
|
+
import { grokProvider } from "./grok/index.ts";
|
|
8
|
+
import type { ProviderAdapter } from "./types.ts";
|
|
9
|
+
import type { ProviderId } from "../ir/types.ts";
|
|
10
|
+
|
|
11
|
+
const providers: Record<ProviderId, ProviderAdapter> = {
|
|
12
|
+
cursor: cursorProvider,
|
|
13
|
+
claude: claudeProvider,
|
|
14
|
+
codex: codexProvider,
|
|
15
|
+
copilot: copilotProvider,
|
|
16
|
+
antigravity: antigravityProvider,
|
|
17
|
+
pi: piProvider,
|
|
18
|
+
grok: grokProvider,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function getProvider(id: ProviderId): ProviderAdapter {
|
|
22
|
+
const provider = providers[id];
|
|
23
|
+
if (!provider) {
|
|
24
|
+
throw new Error(`Unknown provider: ${id}`);
|
|
25
|
+
}
|
|
26
|
+
return provider;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function listProviders(): ProviderId[] {
|
|
30
|
+
return Object.keys(providers) as ProviderId[];
|
|
31
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SourceImportBatch, SourceSessionRef } from "../ir/types.ts";
|
|
2
|
+
|
|
3
|
+
export interface ProviderAdapter {
|
|
4
|
+
name: string;
|
|
5
|
+
parserVersion: string;
|
|
6
|
+
discover(): SourceSessionRef[];
|
|
7
|
+
parseIncremental(
|
|
8
|
+
sourcePath: string,
|
|
9
|
+
sessionId: string,
|
|
10
|
+
offset: number,
|
|
11
|
+
): SourceImportBatch;
|
|
12
|
+
parseFull(sourcePath: string, sessionId: string): SourceImportBatch;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function stringField(value: unknown, key: string): string | undefined {
|
|
16
|
+
if (typeof value !== "object" || value === null) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const field = (value as Record<string, unknown>)[key];
|
|
20
|
+
return typeof field === "string" ? field : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function textFromUnknown(value: unknown): string | undefined {
|
|
24
|
+
if (typeof value === "string") {
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
const parts = value
|
|
29
|
+
.map((item) => textFromUnknown(item))
|
|
30
|
+
.filter((part): part is string => Boolean(part));
|
|
31
|
+
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
32
|
+
}
|
|
33
|
+
if (typeof value === "object" && value !== null) {
|
|
34
|
+
const record = value as Record<string, unknown>;
|
|
35
|
+
if (typeof record.text === "string") {
|
|
36
|
+
return record.text;
|
|
37
|
+
}
|
|
38
|
+
if (typeof record.content === "string") {
|
|
39
|
+
return record.content;
|
|
40
|
+
}
|
|
41
|
+
if (Array.isArray(record.content)) {
|
|
42
|
+
return textFromUnknown(record.content);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function truncate(text: string, max = 2000): string {
|
|
49
|
+
if (text.length <= max) {
|
|
50
|
+
return text;
|
|
51
|
+
}
|
|
52
|
+
return `${text.slice(0, max)}… [truncated ${text.length - max} chars]`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { coordinatorStatePath, syncConfig } from "../config/sync.ts";
|
|
5
|
+
import { coordinatorSpawn } from "../config/runtime.ts";
|
|
6
|
+
import { withDb } from "../db/migrate.ts";
|
|
7
|
+
|
|
8
|
+
export interface CoordinatorState {
|
|
9
|
+
dirty: boolean;
|
|
10
|
+
lastIngestAt: number;
|
|
11
|
+
flushRequestedAt: number | null;
|
|
12
|
+
lastSyncAt: number | null;
|
|
13
|
+
workerPid: number | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_STATE: CoordinatorState = {
|
|
17
|
+
dirty: false,
|
|
18
|
+
lastIngestAt: 0,
|
|
19
|
+
flushRequestedAt: null,
|
|
20
|
+
lastSyncAt: null,
|
|
21
|
+
workerPid: null,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function readState(): CoordinatorState {
|
|
25
|
+
const path = coordinatorStatePath();
|
|
26
|
+
if (!existsSync(path)) {
|
|
27
|
+
return { ...DEFAULT_STATE };
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return { ...DEFAULT_STATE, ...JSON.parse(readFileSync(path, "utf-8")) };
|
|
31
|
+
} catch {
|
|
32
|
+
return { ...DEFAULT_STATE };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeState(state: CoordinatorState): void {
|
|
37
|
+
const path = coordinatorStatePath();
|
|
38
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
39
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function runSync(): Promise<boolean> {
|
|
43
|
+
const config = syncConfig();
|
|
44
|
+
if (!config.enabled) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
await withDb(async (client) => {
|
|
49
|
+
await client.sync();
|
|
50
|
+
});
|
|
51
|
+
const state = readState();
|
|
52
|
+
state.dirty = false;
|
|
53
|
+
state.lastSyncAt = Date.now();
|
|
54
|
+
state.flushRequestedAt = null;
|
|
55
|
+
state.workerPid = null;
|
|
56
|
+
writeState(state);
|
|
57
|
+
return true;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error("[nn-sync] sync failed", error);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
reject(new Error(`sync timed out after ${timeoutMs}ms`));
|
|
68
|
+
}, timeoutMs);
|
|
69
|
+
promise
|
|
70
|
+
.then((value) => {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
resolve(value);
|
|
73
|
+
})
|
|
74
|
+
.catch((error) => {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
reject(error);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function flushSync(): Promise<void> {
|
|
82
|
+
const config = syncConfig();
|
|
83
|
+
if (!config.enabled) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const state = readState();
|
|
88
|
+
state.flushRequestedAt = Date.now();
|
|
89
|
+
state.dirty = true;
|
|
90
|
+
writeState(state);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await withTimeout(runSync(), config.flushTimeoutMs);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error("[nn-sync] flush failed", error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function pullSync(): Promise<void> {
|
|
100
|
+
await flushSync();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function scheduleIncrementalSync(): void {
|
|
104
|
+
const config = syncConfig();
|
|
105
|
+
if (!config.enabled) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const state = readState();
|
|
110
|
+
state.dirty = true;
|
|
111
|
+
state.lastIngestAt = Date.now();
|
|
112
|
+
writeState(state);
|
|
113
|
+
|
|
114
|
+
spawnCoordinatorWorker();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function spawnCoordinatorWorker(): void {
|
|
118
|
+
const state = readState();
|
|
119
|
+
if (state.workerPid) {
|
|
120
|
+
try {
|
|
121
|
+
process.kill(state.workerPid, 0);
|
|
122
|
+
return;
|
|
123
|
+
} catch {
|
|
124
|
+
state.workerPid = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { cmd } = coordinatorSpawn();
|
|
129
|
+
|
|
130
|
+
const proc = Bun.spawn(cmd, {
|
|
131
|
+
env: { ...process.env },
|
|
132
|
+
stdout: "ignore",
|
|
133
|
+
stderr: "ignore",
|
|
134
|
+
stdin: "ignore",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
state.workerPid = proc.pid;
|
|
138
|
+
writeState(state);
|
|
139
|
+
void proc.exited.then(() => {
|
|
140
|
+
const current = readState();
|
|
141
|
+
if (current.workerPid === proc.pid) {
|
|
142
|
+
current.workerPid = null;
|
|
143
|
+
writeState(current);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function getCoordinatorState(): CoordinatorState {
|
|
149
|
+
return readState();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function runCoordinatorOnce(): Promise<void> {
|
|
153
|
+
const config = syncConfig();
|
|
154
|
+
if (!config.enabled) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const state = readState();
|
|
159
|
+
if (!state.dirty) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (state.flushRequestedAt) {
|
|
164
|
+
await runSync();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const elapsed = Date.now() - state.lastIngestAt;
|
|
169
|
+
if (elapsed < config.debounceMs) {
|
|
170
|
+
await Bun.sleep(config.debounceMs - elapsed);
|
|
171
|
+
const latest = readState();
|
|
172
|
+
if (!latest.dirty) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (latest.flushRequestedAt) {
|
|
176
|
+
await runSync();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const quietFor = Date.now() - latest.lastIngestAt;
|
|
180
|
+
if (quietFor < config.debounceMs) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await runSync();
|
|
186
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { dbPath } from "../config/paths.ts";
|
|
2
|
+
import { withDb } from "../db/migrate.ts";
|
|
3
|
+
import { syncConfig, tursoConfigured } from "../config/sync.ts";
|
|
4
|
+
import { getCoordinatorState } from "./coordinator.ts";
|
|
5
|
+
|
|
6
|
+
export interface SyncStatus {
|
|
7
|
+
configured: boolean;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
localPath: string;
|
|
10
|
+
syncUrl: string | null;
|
|
11
|
+
message: string;
|
|
12
|
+
coordinator: ReturnType<typeof getCoordinatorState>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function syncStatus(): Promise<SyncStatus> {
|
|
16
|
+
const localPath = dbPath();
|
|
17
|
+
const configured = tursoConfigured();
|
|
18
|
+
const config = syncConfig();
|
|
19
|
+
|
|
20
|
+
if (!configured) {
|
|
21
|
+
return {
|
|
22
|
+
configured: false,
|
|
23
|
+
enabled: false,
|
|
24
|
+
localPath,
|
|
25
|
+
syncUrl: null,
|
|
26
|
+
message:
|
|
27
|
+
"Turso sync not configured. Set TURSO_DATABASE_URL and TURSO_AUTH_TOKEN.",
|
|
28
|
+
coordinator: getCoordinatorState(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
configured: true,
|
|
34
|
+
enabled: config.enabled,
|
|
35
|
+
localPath,
|
|
36
|
+
syncUrl: process.env.TURSO_DATABASE_URL ?? null,
|
|
37
|
+
message: config.enabled ? "Turso sync configured." : "Turso sync disabled.",
|
|
38
|
+
coordinator: getCoordinatorState(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function syncPush(): Promise<string> {
|
|
43
|
+
if (!syncConfig().enabled) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
"Turso sync not configured. Set TURSO_DATABASE_URL and TURSO_AUTH_TOKEN.",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
await withDb(async (client) => {
|
|
49
|
+
await client.sync();
|
|
50
|
+
});
|
|
51
|
+
return "Synced local changes with Turso.";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function syncPull(): Promise<string> {
|
|
55
|
+
if (!syncConfig().enabled) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
"Turso sync not configured. Set TURSO_DATABASE_URL and TURSO_AUTH_TOKEN.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
await withDb(async (client) => {
|
|
61
|
+
await client.sync();
|
|
62
|
+
});
|
|
63
|
+
return "Synced remote changes with local database.";
|
|
64
|
+
}
|