codemem 0.46.3 → 0.47.0-beta.3
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 -1
- package/dist/index.js +1597 -462
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { ContextOnlyReplayError, DEDUP_KEY_BACKFILL_JOB, DEFAULT_COORDINATOR_DB_PATH, DedupKeyBackfillRunner, DeviceIdentityError, EXTRACTION_BENCHMARK_QUALITY_WEIGHTS, MUTATING_TOOL_NAMES, MemoryStore, ObserverClient, ObserverOutputError, ObserverOutputTransportError, PROMPT_TRANSPORT_PROTOCOL_RANGE, REF_BACKFILL_JOB, RawEventIngestValidationError, RawEventSweeper, RefBackfillRunner, SCOPE_BACKFILL_JOB, SESSION_CONTEXT_BACKFILL_JOB, SUMMARY_DEDUP_BACKFILL_JOB, ScopeBackfillRunner, SessionContextBackfillRunner, SummaryDedupBackfillRunner, SyncRetentionRunner, TRUSTED_HOOK_MAPPER_OPTIONS, VERSION, VectorModelMigrationRunner, aiBackfillStructuredContent, applyBootstrapSnapshot, applyDistillRule, arePromptTransportProtocolRangesCompatible, atomicReplaceConfigFile, backfillMemoryDedupKeys, backfillNarrativeFromBody, backfillTagsText, backfillVectors, buildBaseUrl, buildDirectPeerAuthHeaders, buildDistillReport, buildPiFlushSignalFromEvent, buildRawEventEnvelopeFromHook, buildRawEventEnvelopeFromPiEvent, buildTieredObserverSelection, buildViewerIdentityTarget, classifyPromptTransportFailure, clonePromptPackAttempt, collectOperationalStatus, compareMemoryRoleReports, connect, connectReadOnly, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnrollDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorUpdateScopeAction, createBetterSqliteCoordinatorApp, deactivateLowSignalMemories, deactivateLowSignalObservations, dedupNearDuplicateMemories, describePiObserverStatus, detectInstallKind, draftDistillRule, ensureDeviceIdentity, ensureSchemaBootstrapped, estimateExtractionModelCost, exportMemories, extractApplyPatchPaths, extractionReplayObserverIdentity, fetchAllSnapshotPages, fingerprintPublicKey, flushRawEvents, formatHostPort, getAttributionDiagnostics, getCodememConfigPath, getExtractionBenchmarkProfile, getExtractionModelPricing, getInjectionEvalScenarioPack, getInjectionEvalScenarioPrompts, getMaintenanceJob, getMemoryArtifactReport, getMemoryRoleReport, getRawEventRelinkPlan, getRawEventRelinkReport, getRawEventStatus, getSemanticIndexDiagnostics, getSessionExtractionEval, getSessionExtractionEvalScenario, getUpdateStatus, getWorkspaceCodememConfigPath, hasPendingDedupKeyBackfill, hasPendingRefBackfill, hasPendingScopeBackfill, hasPendingSessionContextBackfill, hasPendingSummaryDedupBackfill, hasUnsyncedSharedMemoryChanges, importMemories, ingestRawEvents, initDatabase, isAutomaticRecallMeasurement, isEmbeddingDisabled, isExplicitUpdateInstallEligible, isReleaseVersionForChannel, judgeDistillReport, listMaintenanceJobs, listPerPeerScopeSyncState, listRetentionScopeIds, loadObserverConfig, loadPublicKey, loadSqliteVec, mdnsEnabled, mutateCodememConfigFile, normalizePromptTransportProtocolRange, observerBaseUrlForProviderOverride, planReplicationOpsAgePrune, probeCodememViewerLiveness, probeCodememViewerLiveness as probeCodememViewerLiveness$1, probeRequiredNativeRuntime, projectMatchesFilter, promptPackArtifactFingerprint, pruneReplicationOpsUntilCaughtUp, rawEventsGate, readCodememConfigFile, readCodememConfigFileAtPath, readCoordinatorSyncConfig, readImportPayload, recordAutomaticRecall, recordPromptPackArtifacts, recordPromptPackTerminal,
|
|
2
|
+
import { ACCOUNT_CONNECTION_MIGRATION_STATEMENTS, ACCOUNT_UNLINK_MIGRATION_STATEMENTS, AUTH_LINK_AUTHORITY_CUTOVER_SQL, ContextOnlyReplayError, CoordinatorAccountLinkError, DEDUP_KEY_BACKFILL_JOB, DEFAULT_COORDINATOR_DB_PATH, DedupKeyBackfillRunner, DeviceIdentityError, EXTRACTION_BENCHMARK_QUALITY_WEIGHTS, FILE_CONTEXT_TRANSPORT_MAX_BYTES, FILE_CONTEXT_TRANSPORT_PROFILE, MUTATING_TOOL_NAMES, MemoryStore, ObserverClient, ObserverOutputError, ObserverOutputTransportError, OwnerProfileEnrollmentError, PROMPT_TRANSPORT_PROTOCOL_RANGE, REF_BACKFILL_JOB, RawEventIngestValidationError, RawEventSweeper, RefBackfillRunner, RemoteCoordinatorRequestError, SCOPE_BACKFILL_JOB, SESSION_CONTEXT_BACKFILL_JOB, SUMMARY_DEDUP_BACKFILL_JOB, ScopeBackfillRunner, SessionContextBackfillRunner, SummaryDedupBackfillRunner, SyncRetentionRunner, TRUSTED_HOOK_MAPPER_OPTIONS, VERSION, VectorModelMigrationRunner, aiBackfillStructuredContent, applyBootstrapSnapshot, applyDistillRule, arePromptTransportProtocolRangesCompatible, atomicReplaceConfigFile, backfillMemoryDedupKeys, backfillNarrativeFromBody, backfillTagsText, backfillVectors, buildBaseUrl, buildDirectPeerAuthHeaders, buildDistillReport, buildPiFlushSignalFromEvent, buildRawEventEnvelopeFromHook, buildRawEventEnvelopeFromPiEvent, buildTieredObserverSelection, buildViewerIdentityTarget, classifyPromptTransportFailure, clonePromptPackAttempt, collectOperationalStatus, compareMemoryRoleReports, connect, connectReadOnly, coordinatorAuthControllerReviewAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnrollDeviceAction, coordinatorGrantIdentityGroupAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorQualifyOwnerAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorReviewInviteAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeDeviceAction, coordinatorRevokeScopeMembershipAction, coordinatorUpdateScopeAction, createBetterSqliteCoordinatorApp, createFileContextRunner, deactivateLowSignalMemories, deactivateLowSignalObservations, dedupNearDuplicateMemories, describePiObserverStatus, detectInstallKind, dispatchFileContextTransport, draftDistillRule, enrollOwnerProfile, ensureDeviceIdentity, ensureSchemaBootstrapped, estimateExtractionModelCost, expandUserPath, exportMemories, extractApplyPatchPaths, extractionReplayObserverIdentity, fetchAllSnapshotPages, fingerprintPublicKey, flushRawEvents, formatHostPort, getAttributionDiagnostics, getCodememConfigPath, getExtractionBenchmarkProfile, getExtractionModelPricing, getInjectionEvalScenarioPack, getInjectionEvalScenarioPrompts, getMaintenanceJob, getMemoryArtifactReport, getMemoryRoleReport, getRawEventRelinkPlan, getRawEventRelinkReport, getRawEventStatus, getSemanticIndexDiagnostics, getSessionExtractionEval, getSessionExtractionEvalScenario, getUpdateStatus, getWorkspaceCodememConfigPath, hasPendingDedupKeyBackfill, hasPendingRefBackfill, hasPendingScopeBackfill, hasPendingSessionContextBackfill, hasPendingSummaryDedupBackfill, hasUnsyncedSharedMemoryChanges, importMemories, importPiSessions, ingestRawEvents, initDatabase, isAutomaticRecallMeasurement, isCoordinatorControllerAttestationReference, isEmbeddingDisabled, isExplicitUpdateInstallEligible, isReleaseVersionForChannel, judgeDistillReport, linkCoordinatorAccount, listMaintenanceJobs, listPerPeerScopeSyncState, listRetentionScopeIds, loadObserverConfig, loadPublicKey, loadRuntimeSigningPublicKey, loadSqliteVec, mdnsEnabled, mutateCodememConfigFile, normalizeCoordinatorAccountLinkOrigin, normalizePromptTransportProtocolRange, observerBaseUrlForProviderOverride, parseFileContextTransportRequest, planReplicationOpsAgePrune, probeCodememViewerLiveness, probeCodememViewerLiveness as probeCodememViewerLiveness$1, probeRequiredNativeRuntime, projectMatchesFilter, promptPackArtifactFingerprint, pruneReplicationOpsUntilCaughtUp, rawEventsGate, readAccountLinkDevice, readCodememConfigFile, readCodememConfigFileAtPath, readCoordinatorAccountLinkStatus, readCoordinatorOwnerReviewLocalEvidence, readCoordinatorSyncConfig, readImportPayload, recordAutomaticRecall, recordPromptPackArtifacts, recordPromptPackTerminal, renderUnifiedDiff, replayBatchExtraction, replayBatchExtractionWithTierRouting, requestJson, resolveCodememConfigPath, resolveComparablePath, resolveDbPath, resolveHookProject, resolveKeyPaths, resolvePiAgentDir, resolvePiObserverConfig, resolveProject, resolveProjectRoot, retryRawEventFailures, runSyncDaemon, runSyncPass, scanSecretsRetroactive, schema, scoreExtractionBenchmarkOutput, searchPiSessions, setPeerProjectFilter, syncPassPreflight, tryUpdateRetrievalDelivery, updatePeerAddresses, upgradeCoordinatorAccountSchemaFile, upgradeCoordinatorAuthSchemaFile, vacuumDatabase, viewerUrl, writeCodememConfigFile } from "@codemem/core";
|
|
3
3
|
import { Command, Option } from "commander";
|
|
4
4
|
import omelette from "omelette";
|
|
5
|
-
import { randomInt, randomUUID } from "node:crypto";
|
|
6
5
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
6
|
import { homedir, networkInterfaces } from "node:os";
|
|
8
7
|
import { dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
|
|
9
|
-
import { styleText } from "node:util";
|
|
8
|
+
import { isDeepStrictEqual, styleText } from "node:util";
|
|
9
|
+
import { randomInt, randomUUID } from "node:crypto";
|
|
10
10
|
import * as p from "@clack/prompts";
|
|
11
11
|
import { serve } from "@hono/node-server";
|
|
12
12
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
@@ -79,6 +79,54 @@ function emitJsonError(errorCode, message, exitCode = 1) {
|
|
|
79
79
|
process.exitCode = exitCode;
|
|
80
80
|
}
|
|
81
81
|
//#endregion
|
|
82
|
+
//#region src/commands/claude-hook-file-context-transport.ts
|
|
83
|
+
/** Internal packaged-hook transport. Normal CLI invocation keeps its original guards/output. */
|
|
84
|
+
function runClaudeFileContextTransport(payload, opts) {
|
|
85
|
+
const parsed = parseFileContextTransportRequest(payload);
|
|
86
|
+
if (!parsed.success) throw new Error("invalid file-context transport request");
|
|
87
|
+
let request = parsed.data;
|
|
88
|
+
if ([
|
|
89
|
+
"1",
|
|
90
|
+
"true",
|
|
91
|
+
"yes",
|
|
92
|
+
"on"
|
|
93
|
+
].includes(String(process.env.CODEMEM_PLUGIN_IGNORE ?? "").trim().toLowerCase())) return {
|
|
94
|
+
ok: true,
|
|
95
|
+
attempt_id: request.attempt_id,
|
|
96
|
+
request_id: request.request_id,
|
|
97
|
+
result: { continue: true },
|
|
98
|
+
delivery_pending: false
|
|
99
|
+
};
|
|
100
|
+
const disabled = (value) => [
|
|
101
|
+
"0",
|
|
102
|
+
"false",
|
|
103
|
+
"off"
|
|
104
|
+
].includes(String(value ?? "").trim().toLowerCase());
|
|
105
|
+
if (disabled(process.env.CODEMEM_RETRIEVAL_LEDGER)) request = {
|
|
106
|
+
...request,
|
|
107
|
+
ledger_enabled: false
|
|
108
|
+
};
|
|
109
|
+
if (request.action === "lookup" && disabled(process.env.CODEMEM_FILE_CONTEXT)) {
|
|
110
|
+
const { relative_path: _path, project: _project, file_mtime_ms: _mtime, ...base } = request;
|
|
111
|
+
request = {
|
|
112
|
+
...base,
|
|
113
|
+
action: "skip",
|
|
114
|
+
reason: "file_context_disabled"
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const dbPath = resolveDbPath(resolveDbOpt(opts));
|
|
118
|
+
if (resolve(dbPath) !== resolve(request.db_path) || !isDeepStrictEqual(buildViewerIdentityTarget(), request.identity_target)) throw new Error("file-context target mismatch");
|
|
119
|
+
const store = new MemoryStore(dbPath);
|
|
120
|
+
try {
|
|
121
|
+
if (!store.hasCurrentIdentity()) throw new Error("file-context identity changed");
|
|
122
|
+
return dispatchFileContextTransport(store, request);
|
|
123
|
+
} finally {
|
|
124
|
+
try {
|
|
125
|
+
store.close();
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
82
130
|
//#region src/commands/claude-hook-plugin-log.ts
|
|
83
131
|
/**
|
|
84
132
|
* Append-only plugin event log used by `claude-hook-inject` and
|
|
@@ -130,24 +178,12 @@ function logHookEvent(message) {
|
|
|
130
178
|
//#endregion
|
|
131
179
|
//#region src/commands/claude-hook-file-context.ts
|
|
132
180
|
var FILE_GATE_MIN_BYTES = 1500;
|
|
133
|
-
var FETCH_LIMIT = 40;
|
|
134
|
-
var DISPLAY_LIMIT = 15;
|
|
135
|
-
var MTIME_FRESH_TOLERANCE_MS = 3e5;
|
|
136
181
|
var SMALL_FILE_BYPASS_PATTERNS = [
|
|
137
182
|
/\.(json|jsonc|toml|ya?ml)$/i,
|
|
138
183
|
/\.env(\.|$)/i,
|
|
139
184
|
/(^|\/)dockerfile(\.|$)/i,
|
|
140
185
|
/\.config\.(js|ts|mjs|cjs|json)$/i
|
|
141
186
|
];
|
|
142
|
-
var KIND_ICONS = {
|
|
143
|
-
decision: "⚖️",
|
|
144
|
-
bugfix: "🔴",
|
|
145
|
-
feature: "🟢",
|
|
146
|
-
refactor: "🔄",
|
|
147
|
-
discovery: "🔵",
|
|
148
|
-
change: "✅",
|
|
149
|
-
exploration: "🔬"
|
|
150
|
-
};
|
|
151
187
|
function emitJson$2(value) {
|
|
152
188
|
console.log(JSON.stringify(value));
|
|
153
189
|
}
|
|
@@ -187,105 +223,6 @@ function statFile(absPath) {
|
|
|
187
223
|
return null;
|
|
188
224
|
}
|
|
189
225
|
}
|
|
190
|
-
function parseJsonArray(value) {
|
|
191
|
-
if (!value) return [];
|
|
192
|
-
try {
|
|
193
|
-
const parsed = JSON.parse(value);
|
|
194
|
-
if (!Array.isArray(parsed)) return [];
|
|
195
|
-
return parsed.filter((item) => typeof item === "string");
|
|
196
|
-
} catch {
|
|
197
|
-
return [];
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
function normalizePathForCompare(path) {
|
|
201
|
-
return path.replace(/\\/g, "/");
|
|
202
|
-
}
|
|
203
|
-
function scoreRow(row, normalizedTarget, idx) {
|
|
204
|
-
const filesModified = parseJsonArray(row.files_modified);
|
|
205
|
-
const inModified = filesModified.some((f) => normalizePathForCompare(f) === normalizedTarget);
|
|
206
|
-
let score = 0;
|
|
207
|
-
if (inModified) score += 2;
|
|
208
|
-
if (filesModified.length <= 1) score += 2;
|
|
209
|
-
else if (filesModified.length <= 3) score += 1;
|
|
210
|
-
return {
|
|
211
|
-
row,
|
|
212
|
-
score,
|
|
213
|
-
idx
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
function scoreAndDedupe(rows, targetPath, limit) {
|
|
217
|
-
const normalizedTarget = normalizePathForCompare(targetPath);
|
|
218
|
-
const scored = rows.map((row, idx) => scoreRow(row, normalizedTarget, idx));
|
|
219
|
-
const bestPerSession = /* @__PURE__ */ new Map();
|
|
220
|
-
for (const item of scored) {
|
|
221
|
-
const existing = bestPerSession.get(item.row.session_id);
|
|
222
|
-
if (!existing || item.score > existing.score || item.score === existing.score && item.idx < existing.idx) bestPerSession.set(item.row.session_id, item);
|
|
223
|
-
}
|
|
224
|
-
const deduped = Array.from(bestPerSession.values());
|
|
225
|
-
deduped.sort((a, b) => b.score - a.score || a.idx - b.idx);
|
|
226
|
-
return deduped.slice(0, limit).map((s) => s.row);
|
|
227
|
-
}
|
|
228
|
-
function compactTime(timeStr) {
|
|
229
|
-
return timeStr.toLowerCase().replace(" am", "a").replace(" pm", "p");
|
|
230
|
-
}
|
|
231
|
-
function formatTime(epochMs) {
|
|
232
|
-
return new Date(epochMs).toLocaleString("en-US", {
|
|
233
|
-
hour: "numeric",
|
|
234
|
-
minute: "2-digit",
|
|
235
|
-
hour12: true
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
function formatDate(epochMs) {
|
|
239
|
-
return new Date(epochMs).toLocaleString("en-US", {
|
|
240
|
-
month: "short",
|
|
241
|
-
day: "numeric",
|
|
242
|
-
year: "numeric"
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
function formatTimeline(rows, filePath, staleness) {
|
|
246
|
-
const safePath = filePath.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n");
|
|
247
|
-
const enriched = rows.map((row) => ({
|
|
248
|
-
row,
|
|
249
|
-
epochMs: Date.parse(row.created_at)
|
|
250
|
-
})).filter((item) => Number.isFinite(item.epochMs) && item.epochMs > 0);
|
|
251
|
-
const byDay = /* @__PURE__ */ new Map();
|
|
252
|
-
for (const item of enriched) {
|
|
253
|
-
const day = formatDate(item.epochMs);
|
|
254
|
-
const bucket = byDay.get(day);
|
|
255
|
-
if (bucket) bucket.push(item);
|
|
256
|
-
else byDay.set(day, [item]);
|
|
257
|
-
}
|
|
258
|
-
const sortedDays = Array.from(byDay.entries()).sort((a, b) => {
|
|
259
|
-
return Math.min(...a[1].map((i) => i.epochMs)) - Math.min(...b[1].map((i) => i.epochMs));
|
|
260
|
-
});
|
|
261
|
-
const ids = rows.map((r) => r.id);
|
|
262
|
-
const lines = [`This file (${safePath}) has prior codemem observations. The Read result below is unchanged.`, `- Fetch full bodies on demand: memory.get_observations([${ids.join(", ")}]).`];
|
|
263
|
-
if (staleness) {
|
|
264
|
-
const driftMinutes = Math.max(1, Math.round((staleness.fileMtimeMs - staleness.newestObservationMs) / 6e4));
|
|
265
|
-
lines.unshift(`Heads up: this file was modified ~${driftMinutes} min after the most recent observation below. Past entries may be partially stale — verify against the Read result before relying on them.`);
|
|
266
|
-
}
|
|
267
|
-
for (const [day, dayItems] of sortedDays) {
|
|
268
|
-
const chronological = [...dayItems].sort((a, b) => a.epochMs - b.epochMs);
|
|
269
|
-
lines.push(`### ${day}`);
|
|
270
|
-
for (const { row, epochMs } of chronological) {
|
|
271
|
-
const title = (row.title || "Untitled").replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 160);
|
|
272
|
-
const icon = KIND_ICONS[row.kind] ?? "❔";
|
|
273
|
-
const time = compactTime(formatTime(epochMs));
|
|
274
|
-
lines.push(`${row.id} ${time} ${icon} (${row.kind}) ${title}`);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
return lines.join("\n");
|
|
278
|
-
}
|
|
279
|
-
function queryByFile(dbPath, relativePath, project, limit) {
|
|
280
|
-
const store = new MemoryStore(dbPath);
|
|
281
|
-
try {
|
|
282
|
-
const opts = { limit };
|
|
283
|
-
if (project) opts.project = project;
|
|
284
|
-
return store.findByFile(relativePath, opts);
|
|
285
|
-
} finally {
|
|
286
|
-
store.close();
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
226
|
function resolveProject$1(payload) {
|
|
290
227
|
const cwd = typeof payload.cwd === "string" ? payload.cwd : null;
|
|
291
228
|
return resolveHookProject(cwd, payload.project);
|
|
@@ -294,73 +231,45 @@ function sourceSessionId(payload) {
|
|
|
294
231
|
const value = payload.session_id;
|
|
295
232
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
296
233
|
}
|
|
297
|
-
|
|
298
|
-
if (envTruthy$4(process.env.CODEMEM_PLUGIN_IGNORE)) return continueResult$2();
|
|
299
|
-
const filePath = extractFilePath(payload);
|
|
300
|
-
if (!filePath) return continueResult$2();
|
|
301
|
-
const startedAt = (deps.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
302
|
-
const attemptId = (deps.createAttemptId ?? randomUUID)();
|
|
303
|
-
const resolveDb = deps.resolveDb ?? resolveDbPath;
|
|
234
|
+
function fileContextStore(opts, deps) {
|
|
304
235
|
let resolvedDbPath = null;
|
|
305
236
|
let activeStore = null;
|
|
306
|
-
const
|
|
307
|
-
resolvedDbPath ??= resolveDb(resolveDbOpt(opts));
|
|
308
|
-
|
|
309
|
-
return activeStore;
|
|
237
|
+
const dbPath = () => {
|
|
238
|
+
resolvedDbPath ??= (deps.resolveDb ?? resolveDbPath)(resolveDbOpt(opts));
|
|
239
|
+
return resolvedDbPath;
|
|
310
240
|
};
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
} catch {}
|
|
346
|
-
};
|
|
347
|
-
const updateDelivery = (status) => {
|
|
348
|
-
if (!envNotDisabled$3(process.env.CODEMEM_RETRIEVAL_LEDGER || "1")) return;
|
|
349
|
-
try {
|
|
350
|
-
resolvedDbPath ??= resolveDb(resolveDbOpt(opts));
|
|
351
|
-
if (deps.updateDelivery) deps.updateDelivery(resolvedDbPath, attemptId, status);
|
|
352
|
-
else tryUpdateRetrievalDelivery(getStore().db, attemptId, status);
|
|
353
|
-
} catch {}
|
|
354
|
-
};
|
|
355
|
-
if (!envNotDisabled$3(process.env.CODEMEM_FILE_CONTEXT || "1")) {
|
|
356
|
-
record({
|
|
357
|
-
retrievalStatus: "skipped",
|
|
358
|
-
deliveryStatus: "not_attempted",
|
|
359
|
-
failureCode: "file_context_disabled",
|
|
360
|
-
failureStage: "configuration"
|
|
361
|
-
});
|
|
362
|
-
return finish(continueResult$2());
|
|
363
|
-
}
|
|
241
|
+
return {
|
|
242
|
+
dbPath,
|
|
243
|
+
getStore: () => {
|
|
244
|
+
activeStore ??= (deps.createStore ?? ((path) => new MemoryStore(path)))(dbPath());
|
|
245
|
+
return activeStore;
|
|
246
|
+
},
|
|
247
|
+
finish: (result) => {
|
|
248
|
+
try {
|
|
249
|
+
activeStore?.close();
|
|
250
|
+
} catch {}
|
|
251
|
+
activeStore = null;
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function fileContextRunner(payload, store, deps) {
|
|
257
|
+
const findByFile = deps.queryByFile;
|
|
258
|
+
const recordAttempt = deps.recordAttempt;
|
|
259
|
+
const updateDelivery = deps.updateDelivery;
|
|
260
|
+
return createFileContextRunner({
|
|
261
|
+
getStore: store.getStore,
|
|
262
|
+
sourceSessionId: sourceSessionId(payload),
|
|
263
|
+
ledgerEnabled: () => envNotDisabled$3(process.env.CODEMEM_RETRIEVAL_LEDGER || "1"),
|
|
264
|
+
now: deps.now,
|
|
265
|
+
createAttemptId: deps.createAttemptId,
|
|
266
|
+
log: logHookEvent,
|
|
267
|
+
...findByFile ? { findByFile: (path, options) => findByFile(store.dbPath(), path, options.project ?? null, options.limit) } : {},
|
|
268
|
+
...recordAttempt ? { recordAttempt: (input) => recordAttempt(store.dbPath(), input) } : {},
|
|
269
|
+
...updateDelivery ? { updateDelivery: (attemptId, status) => updateDelivery(store.dbPath(), attemptId, status) } : {}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function fileContextPath(filePath, payload) {
|
|
364
273
|
const cwd = typeof payload.cwd === "string" && payload.cwd.trim() ? payload.cwd : process.cwd();
|
|
365
274
|
const expandedPath = expandHome$2(filePath);
|
|
366
275
|
const absolutePath = isAbsolute(expandedPath) ? expandedPath : resolve(cwd, expandedPath);
|
|
@@ -368,133 +277,78 @@ async function buildClaudeFileContext(payload, opts, deps = {}) {
|
|
|
368
277
|
const escapesCwd = relativePath === ".." || relativePath.startsWith("../") || isAbsolute(relativePath);
|
|
369
278
|
if (!relativePath || escapesCwd) {
|
|
370
279
|
logHookEvent(`file_context.skip reason=outside_cwd path=${JSON.stringify(filePath)} cwd=${JSON.stringify(cwd)}`);
|
|
371
|
-
|
|
372
|
-
retrievalStatus: "skipped",
|
|
373
|
-
deliveryStatus: "not_attempted",
|
|
374
|
-
failureCode: "outside_cwd",
|
|
375
|
-
failureStage: "path_validation"
|
|
376
|
-
});
|
|
377
|
-
return finish(continueResult$2());
|
|
280
|
+
return null;
|
|
378
281
|
}
|
|
282
|
+
return {
|
|
283
|
+
absolutePath,
|
|
284
|
+
relativePath
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
async function buildClaudeFileContext(payload, opts, deps = {}) {
|
|
288
|
+
if (envTruthy$4(process.env.CODEMEM_PLUGIN_IGNORE)) return continueResult$2();
|
|
289
|
+
const filePath = extractFilePath(payload);
|
|
290
|
+
if (!filePath) return continueResult$2();
|
|
291
|
+
const store = fileContextStore(opts, deps);
|
|
292
|
+
const runner = fileContextRunner(payload, store, deps);
|
|
293
|
+
if (!envNotDisabled$3(process.env.CODEMEM_FILE_CONTEXT || "1")) return store.finish(runner.skip({ reason: "file_context_disabled" }));
|
|
294
|
+
const path = fileContextPath(filePath, payload);
|
|
295
|
+
if (!path) return store.finish(runner.skip({ reason: "outside_cwd" }));
|
|
296
|
+
const { absolutePath, relativePath } = path;
|
|
379
297
|
const minBytes = Number.parseInt(process.env.CODEMEM_FILE_CONTEXT_MIN_BYTES ?? `${FILE_GATE_MIN_BYTES}`, 10);
|
|
380
298
|
const minBytesEffective = Number.isFinite(minBytes) && minBytes >= 0 ? minBytes : FILE_GATE_MIN_BYTES;
|
|
381
299
|
const stat = (deps.statFile ?? statFile)(absolutePath);
|
|
382
300
|
if (!stat) {
|
|
383
301
|
logHookEvent(`file_context.skip reason=stat_failed path=${JSON.stringify(relativePath)}`);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
deliveryStatus: "not_attempted",
|
|
387
|
-
failureCode: "stat_failed",
|
|
388
|
-
failureStage: "file_access",
|
|
302
|
+
return store.finish(runner.skip({
|
|
303
|
+
reason: "stat_failed",
|
|
389
304
|
repositoryPaths: [relativePath]
|
|
390
|
-
});
|
|
391
|
-
return finish(continueResult$2());
|
|
305
|
+
}));
|
|
392
306
|
}
|
|
393
307
|
const bypassSizeGate = SMALL_FILE_BYPASS_PATTERNS.some((p) => p.test(relativePath));
|
|
394
308
|
if (stat.sizeBytes < minBytesEffective && !bypassSizeGate) {
|
|
395
309
|
logHookEvent(`file_context.skip reason=below_size_gate path=${JSON.stringify(relativePath)} size=${stat.sizeBytes} gate=${minBytesEffective}`);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
deliveryStatus: "not_attempted",
|
|
399
|
-
failureCode: "below_size_gate",
|
|
400
|
-
failureStage: "size_gate",
|
|
401
|
-
repositoryPaths: [relativePath]
|
|
402
|
-
});
|
|
403
|
-
return finish(continueResult$2());
|
|
404
|
-
}
|
|
405
|
-
const project = resolveProject$1(payload);
|
|
406
|
-
const queryFn = deps.queryByFile ?? queryByFile;
|
|
407
|
-
let rows = [];
|
|
408
|
-
try {
|
|
409
|
-
resolvedDbPath ??= resolveDb(resolveDbOpt(opts));
|
|
410
|
-
rows = deps.queryByFile ? queryFn(resolvedDbPath, relativePath, project, FETCH_LIMIT) : getStore().findByFile(relativePath, {
|
|
411
|
-
limit: FETCH_LIMIT,
|
|
412
|
-
...project ? { project } : {}
|
|
413
|
-
});
|
|
414
|
-
} catch (err) {
|
|
415
|
-
logHookEvent(`codemem claude-hook-file-context query failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
416
|
-
record({
|
|
417
|
-
retrievalStatus: "failed",
|
|
418
|
-
deliveryStatus: "not_attempted",
|
|
419
|
-
failureCode: "query_failed",
|
|
420
|
-
failureStage: "retrieval",
|
|
421
|
-
project,
|
|
422
|
-
filters: project ? { project } : void 0,
|
|
310
|
+
return store.finish(runner.skip({
|
|
311
|
+
reason: "below_size_gate",
|
|
423
312
|
repositoryPaths: [relativePath]
|
|
424
|
-
});
|
|
425
|
-
return finish(continueResult$2());
|
|
426
|
-
}
|
|
427
|
-
if (rows.length === 0) {
|
|
428
|
-
logHookEvent(`file_context.skip reason=no_observations path=${JSON.stringify(relativePath)} project=${JSON.stringify(project ?? "")}`);
|
|
429
|
-
record({
|
|
430
|
-
retrievalStatus: "no_results",
|
|
431
|
-
deliveryStatus: "not_attempted",
|
|
432
|
-
project,
|
|
433
|
-
filters: project ? { project } : void 0,
|
|
434
|
-
repositoryPaths: [relativePath]
|
|
435
|
-
});
|
|
436
|
-
return finish(continueResult$2());
|
|
437
|
-
}
|
|
438
|
-
const top = scoreAndDedupe(rows, relativePath, DISPLAY_LIMIT);
|
|
439
|
-
if (top.length === 0) {
|
|
440
|
-
logHookEvent(`file_context.skip reason=no_top_after_dedupe path=${JSON.stringify(relativePath)} candidates=${rows.length}`);
|
|
441
|
-
record({
|
|
442
|
-
retrievalStatus: "succeeded",
|
|
443
|
-
deliveryStatus: "not_attempted",
|
|
444
|
-
candidateIds: rows.map((row) => row.id),
|
|
445
|
-
candidateCount: rows.length,
|
|
446
|
-
selectedIds: [],
|
|
447
|
-
failureCode: "no_top_after_dedupe",
|
|
448
|
-
failureStage: "selection",
|
|
449
|
-
project,
|
|
450
|
-
filters: project ? { project } : void 0,
|
|
451
|
-
repositoryPaths: [relativePath]
|
|
452
|
-
});
|
|
453
|
-
return finish(continueResult$2());
|
|
454
|
-
}
|
|
455
|
-
let staleness = null;
|
|
456
|
-
if (stat.mtimeMs > 0) {
|
|
457
|
-
const newestObservationMs = top.reduce((max, row) => {
|
|
458
|
-
const epoch = Date.parse(row.created_at);
|
|
459
|
-
return Number.isFinite(epoch) && epoch > max ? epoch : max;
|
|
460
|
-
}, 0);
|
|
461
|
-
if (newestObservationMs > 0 && stat.mtimeMs > newestObservationMs + MTIME_FRESH_TOLERANCE_MS) staleness = {
|
|
462
|
-
fileMtimeMs: stat.mtimeMs,
|
|
463
|
-
newestObservationMs
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
record({
|
|
467
|
-
retrievalStatus: "succeeded",
|
|
468
|
-
deliveryStatus: "not_attempted",
|
|
469
|
-
candidateIds: rows.map((row) => row.id),
|
|
470
|
-
candidateCount: rows.length,
|
|
471
|
-
selectedIds: top.map((row) => row.id),
|
|
472
|
-
project,
|
|
473
|
-
filters: project ? { project } : void 0,
|
|
474
|
-
repositoryPaths: [relativePath]
|
|
475
|
-
});
|
|
476
|
-
let timeline;
|
|
477
|
-
try {
|
|
478
|
-
timeline = formatTimeline(top, relativePath, staleness);
|
|
479
|
-
} catch {
|
|
480
|
-
updateDelivery("failed");
|
|
481
|
-
return finish(continueResult$2());
|
|
313
|
+
}));
|
|
482
314
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
additionalContext: timeline
|
|
489
|
-
} });
|
|
315
|
+
return store.finish(runner.run({
|
|
316
|
+
relativePath,
|
|
317
|
+
project: resolveProject$1(payload),
|
|
318
|
+
fileMtimeMs: stat.mtimeMs
|
|
319
|
+
}));
|
|
490
320
|
}
|
|
491
321
|
var claudeHookFileContextCmd = new Command("claude-hook-file-context").configureHelp(helpStyle).description("Return Claude PreToolUse:Read additionalContext from per-file observation timeline");
|
|
492
322
|
addDbOption(claudeHookFileContextCmd);
|
|
323
|
+
claudeHookFileContextCmd.addOption(new Option("--transport-profile").hideHelp());
|
|
324
|
+
claudeHookFileContextCmd.addOption(new Option("--transport").hideHelp());
|
|
493
325
|
var claudeHookFileContextCommand = claudeHookFileContextCmd.action(async (opts) => {
|
|
326
|
+
if (opts.transportProfile) {
|
|
327
|
+
process.stdout.write(`${JSON.stringify(FILE_CONTEXT_TRANSPORT_PROFILE)}\n`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
494
330
|
let raw = "";
|
|
495
|
-
for await (const chunk of process.stdin)
|
|
331
|
+
for await (const chunk of process.stdin) {
|
|
332
|
+
raw += String(chunk);
|
|
333
|
+
if (opts.transport && Buffer.byteLength(raw) > FILE_CONTEXT_TRANSPORT_MAX_BYTES) {
|
|
334
|
+
emitError$2({
|
|
335
|
+
error: "parse_error",
|
|
336
|
+
message: "transport payload too large"
|
|
337
|
+
});
|
|
338
|
+
process.exitCode = 1;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
496
342
|
const trimmed = raw.trim();
|
|
497
343
|
if (!trimmed) {
|
|
344
|
+
if (opts.transport) {
|
|
345
|
+
process.stdout.write(`${JSON.stringify({
|
|
346
|
+
error: "parse_error",
|
|
347
|
+
message: "transport request required"
|
|
348
|
+
})}\n`);
|
|
349
|
+
process.exitCode = 1;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
498
352
|
emitJson$2(continueResult$2());
|
|
499
353
|
return;
|
|
500
354
|
}
|
|
@@ -518,8 +372,23 @@ var claudeHookFileContextCommand = claudeHookFileContextCmd.action(async (opts)
|
|
|
518
372
|
process.exitCode = 1;
|
|
519
373
|
return;
|
|
520
374
|
}
|
|
521
|
-
|
|
375
|
+
await emitFileContext(payload, opts);
|
|
522
376
|
});
|
|
377
|
+
async function emitFileContext(payload, opts) {
|
|
378
|
+
try {
|
|
379
|
+
if (opts.transport) {
|
|
380
|
+
process.stdout.write(`${JSON.stringify(runClaudeFileContextTransport(payload, opts))}\n`);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
emitJson$2(await buildClaudeFileContext(payload, opts));
|
|
384
|
+
} catch {
|
|
385
|
+
emitError$2({
|
|
386
|
+
error: "file_context_failed",
|
|
387
|
+
message: "file context unavailable"
|
|
388
|
+
});
|
|
389
|
+
process.exitCode = 1;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
523
392
|
//#endregion
|
|
524
393
|
//#region src/commands/hook-ingest-live-spool.ts
|
|
525
394
|
function spoolLiveHookPayload(dir, payload, logPrefix) {
|
|
@@ -2525,63 +2394,994 @@ workspaceCmd.action((workspaceIdArg, opts) => {
|
|
|
2525
2394
|
process.exitCode = 2;
|
|
2526
2395
|
return;
|
|
2527
2396
|
}
|
|
2528
|
-
const result = runWorkspaceConfigCommand({
|
|
2529
|
-
...opts,
|
|
2530
|
-
workspaceId
|
|
2531
|
-
});
|
|
2532
|
-
if (opts.json) {
|
|
2533
|
-
console.log(JSON.stringify(result, null, 2));
|
|
2397
|
+
const result = runWorkspaceConfigCommand({
|
|
2398
|
+
...opts,
|
|
2399
|
+
workspaceId
|
|
2400
|
+
});
|
|
2401
|
+
if (opts.json) {
|
|
2402
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
console.log(`Updated workspace config: ${result.config_path}`);
|
|
2406
|
+
console.log(`Updated keys: ${result.updated_keys.join(", ")}`);
|
|
2407
|
+
} catch (err) {
|
|
2408
|
+
if (opts.json) console.log(JSON.stringify({
|
|
2409
|
+
error: "config_error",
|
|
2410
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2411
|
+
}));
|
|
2412
|
+
else console.error(err instanceof Error ? err.message : String(err));
|
|
2413
|
+
process.exitCode = 1;
|
|
2414
|
+
}
|
|
2415
|
+
});
|
|
2416
|
+
configCommand.addCommand(workspaceCmd);
|
|
2417
|
+
function formatWhereHuman(result) {
|
|
2418
|
+
const lines = [];
|
|
2419
|
+
const allEntries = [result.resolved, ...result.fallbackChain];
|
|
2420
|
+
const sourceOrder = {
|
|
2421
|
+
"cli-flag": 0,
|
|
2422
|
+
"env-codemem-config": 1,
|
|
2423
|
+
"env-runtime-root": 2,
|
|
2424
|
+
"env-workspace-id": 3,
|
|
2425
|
+
"legacy-global": 4
|
|
2426
|
+
};
|
|
2427
|
+
allEntries.sort((a, b) => (sourceOrder[a.source] ?? 99) - (sourceOrder[b.source] ?? 99));
|
|
2428
|
+
for (const entry of allEntries) {
|
|
2429
|
+
const marker = entry === result.resolved ? ">>>" : " ";
|
|
2430
|
+
const existsLabel = entry.exists ? "exists" : "missing";
|
|
2431
|
+
lines.push(`${marker} [${entry.source}] ${entry.path}`);
|
|
2432
|
+
lines.push(` ${entry.reason} (${existsLabel})`);
|
|
2433
|
+
}
|
|
2434
|
+
return lines.join("\n");
|
|
2435
|
+
}
|
|
2436
|
+
var whereCmd = new Command("where").configureHelp(helpStyle).description("show resolved config file path");
|
|
2437
|
+
addConfigOption(whereCmd);
|
|
2438
|
+
addJsonOption(whereCmd);
|
|
2439
|
+
whereCmd.action((opts) => {
|
|
2440
|
+
try {
|
|
2441
|
+
const result = resolveCodememConfigPath(opts.config, "read");
|
|
2442
|
+
if (opts.json) console.log(JSON.stringify(result, null, 2));
|
|
2443
|
+
else console.log(formatWhereHuman(result));
|
|
2444
|
+
} catch (err) {
|
|
2445
|
+
if (opts.json) console.log(JSON.stringify({
|
|
2446
|
+
error: "config_error",
|
|
2447
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2448
|
+
}));
|
|
2449
|
+
else console.error(err instanceof Error ? err.message : String(err));
|
|
2450
|
+
process.exitCode = 1;
|
|
2451
|
+
}
|
|
2452
|
+
});
|
|
2453
|
+
configCommand.addCommand(whereCmd);
|
|
2454
|
+
//#endregion
|
|
2455
|
+
//#region src/commands/coordinator-auth-schema.ts
|
|
2456
|
+
function failure$4(options, message, exitCode, errorCode = "auth_schema_upgrade_failed") {
|
|
2457
|
+
if (options.json) {
|
|
2458
|
+
emitJsonError(exitCode === 2 ? "usage_error" : errorCode, message, exitCode);
|
|
2459
|
+
return;
|
|
2460
|
+
}
|
|
2461
|
+
p.log.error(`Coordinator auth upgrade stopped: ${message}`);
|
|
2462
|
+
process.exitCode = exitCode;
|
|
2463
|
+
}
|
|
2464
|
+
function upgrade(options) {
|
|
2465
|
+
const path = resolveDbOpt(options);
|
|
2466
|
+
if (options.exportSql && path) {
|
|
2467
|
+
failure$4(options, "Use --export-sql without --db-path; export opens no database.", 2);
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
if (!options.exportSql && !path) {
|
|
2471
|
+
failure$4(options, "Select an existing coordinator database with --db-path. Pause auth writers, drain attempts and back up the database first.", 2);
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
try {
|
|
2475
|
+
if (options.exportSql) {
|
|
2476
|
+
const sql = `${AUTH_LINK_AUTHORITY_CUTOVER_SQL}\n`;
|
|
2477
|
+
process.stdout.write(options.json ? `${JSON.stringify({
|
|
2478
|
+
sql,
|
|
2479
|
+
applied: false
|
|
2480
|
+
})}\n` : sql);
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
2483
|
+
if (!path) return;
|
|
2484
|
+
upgradeCoordinatorAuthSchemaFile(path);
|
|
2485
|
+
process.stdout.write(options.json ? `${JSON.stringify({ upgraded: true })}\n` : "Coordinator authentication schema upgraded.\n");
|
|
2486
|
+
} catch (error) {
|
|
2487
|
+
failure$4(options, `Upgrade aborted without committing changes. Check that attempts are drained and the legacy schema and provenance match the reviewed source. ${error instanceof Error ? error.message : "Coordinator auth upgrade failed."}`, 1);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
function buildCoordinatorAuthSchemaCommand() {
|
|
2491
|
+
const command = new Command("upgrade-auth-schema").configureHelp(helpStyle).description("Upgrade local coordinator auth or export reviewed D1 SQL").option("-e, --export-sql", "print D1 cutover SQL without opening a database");
|
|
2492
|
+
addDbOption(command);
|
|
2493
|
+
addJsonOption(command);
|
|
2494
|
+
command.action(upgrade);
|
|
2495
|
+
return command;
|
|
2496
|
+
}
|
|
2497
|
+
var accountPrerequisites = "Pause auth writers, drain pending attempts and back up the selected coordinator database first. This command does not pause writers or create a backup.";
|
|
2498
|
+
function hasDatabaseSelection(options) {
|
|
2499
|
+
return options.dbPath !== void 0 || options.db !== void 0;
|
|
2500
|
+
}
|
|
2501
|
+
function exportAccountSchema(options) {
|
|
2502
|
+
const statements = options.deviceUnlink ? ACCOUNT_UNLINK_MIGRATION_STATEMENTS : ACCOUNT_CONNECTION_MIGRATION_STATEMENTS;
|
|
2503
|
+
const text = `-- ${accountPrerequisites}\n-- Execute in ONE atomic D1 batch; do not split into autocommitted requests. Nothing has been applied.\n${statements.map((s) => `${s.trim().replace(/;$/u, "")};`).join("\n")}\n`;
|
|
2504
|
+
process.stdout.write(options.json ? `${JSON.stringify({
|
|
2505
|
+
statements,
|
|
2506
|
+
applied: false
|
|
2507
|
+
})}\n` : text);
|
|
2508
|
+
}
|
|
2509
|
+
async function upgradeAccount(options) {
|
|
2510
|
+
if (options.deviceUnlink && !options.exportSql) {
|
|
2511
|
+
failure$4(options, "Use --device-unlink only with --export-sql; local upgrades detect the installed schema.", 2, "account_schema_upgrade_failed");
|
|
2512
|
+
return;
|
|
2513
|
+
}
|
|
2514
|
+
const path = resolveDbOpt(options);
|
|
2515
|
+
const selected = hasDatabaseSelection(options);
|
|
2516
|
+
if (options.exportSql && selected || !options.exportSql && !path) {
|
|
2517
|
+
failure$4(options, options.exportSql ? "Use --export-sql without --db-path; export opens no database." : `Select an existing coordinator database with --db-path. ${accountPrerequisites}`, 2, "account_schema_upgrade_failed");
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
try {
|
|
2521
|
+
if (options.exportSql) {
|
|
2522
|
+
exportAccountSchema(options);
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
if (!path) return;
|
|
2526
|
+
if (!options.json) process.stderr.write(`${accountPrerequisites}\n`);
|
|
2527
|
+
await upgradeCoordinatorAccountSchemaFile(path);
|
|
2528
|
+
process.stdout.write(options.json ? `${JSON.stringify({ upgraded: true })}\n` : "Coordinator account schema upgraded.\n");
|
|
2529
|
+
} catch (error) {
|
|
2530
|
+
failure$4(options, `Account schema upgrade stopped. Inspect the selected database before retrying. ${error instanceof Error ? error.message : "Coordinator account schema upgrade failed."}`, 1, "account_schema_upgrade_failed");
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
function buildCoordinatorAccountSchemaCommand() {
|
|
2534
|
+
const command = new Command("upgrade-account-schema").configureHelp(helpStyle).description("Explicitly install optional coordinator account management on an existing V2 database").option("--device-unlink", "export only the delta for the prior account-management schema (requires --export-sql)").option("-e, --export-sql", "export statement boundaries for one atomic D1 batch without opening a database").addHelpText("after", `\n${accountPrerequisites}\nD1 export does not apply SQL or verify remote execution limits.\n`);
|
|
2535
|
+
addDbOption(command);
|
|
2536
|
+
addJsonOption(command);
|
|
2537
|
+
command.action(upgradeAccount);
|
|
2538
|
+
return command;
|
|
2539
|
+
}
|
|
2540
|
+
//#endregion
|
|
2541
|
+
//#region src/commands/coordinator-enroll-owner.ts
|
|
2542
|
+
var CAPTURE_WARNING = "Stop OpenCode capture, the Viewer and the sync daemon before preparing or resuming. Keep them stopped until Verified coordinator Identity is shown. Any capture/history change during the browser ceremony can stop local adoption after the server commits, leaving DB and config separate and requiring operator review.";
|
|
2543
|
+
function showPrivateBrowserLink$1(privateUrl) {
|
|
2544
|
+
p.log.warn("Keep this browser link private. Open it to review the Google account and device, then return here to finish. No Team or Project access is added.", { output: process.stderr });
|
|
2545
|
+
p.log.message(privateUrl, { output: process.stderr });
|
|
2546
|
+
}
|
|
2547
|
+
function showAccountAdmissionLink(url, request) {
|
|
2548
|
+
if (!request) throw new OwnerProfileEnrollmentError("review_required");
|
|
2549
|
+
p.log.warn("Open this account admission page while signed in to the intended account. Upload the private capability-request.json file below unchanged, compare the local fingerprint, and review the device. Keep this command running and capture stopped; browser confirmation alone does not finish enrollment. No Team or Project access is added.", { output: process.stderr });
|
|
2550
|
+
p.log.message(`${url}\nPrivate request file: ${request.requestFile}\nDevice: ${request.deviceId}\nLocal fingerprint: ${request.fingerprint}`, { output: process.stderr });
|
|
2551
|
+
}
|
|
2552
|
+
function admissionOptions(options) {
|
|
2553
|
+
if (options.accountAdmission) return {
|
|
2554
|
+
admissionMode: "account",
|
|
2555
|
+
onBrowserStart: showAccountAdmissionLink
|
|
2556
|
+
};
|
|
2557
|
+
return {
|
|
2558
|
+
admissionMode: "operator",
|
|
2559
|
+
onBrowserStart: showPrivateBrowserLink$1
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
function reportUsageError(options, message) {
|
|
2563
|
+
if (options.json) emitJsonError("usage_error", message, 2);
|
|
2564
|
+
else {
|
|
2565
|
+
p.log.error(message, { output: process.stderr });
|
|
2566
|
+
process.exitCode = 2;
|
|
2567
|
+
}
|
|
2568
|
+
return false;
|
|
2569
|
+
}
|
|
2570
|
+
function usage(options) {
|
|
2571
|
+
if (options.prepare === options.resume || options.prepare && (options.capability || options.reviewDigest || options.accountAdmission) || options.accountAdmission && options.capability !== void 0 || options.resume && !options.reviewDigest || options.resume && (options.json || !process.stderr.isTTY)) return reportUsageError(options, "Use --prepare first, then review its device, current Identity, capture counts and digest. Resume interactively with --resume --review-digest; use either --capability for operator admission or --account-admission for signed-in account admission, never both.");
|
|
2572
|
+
if (options.capability !== void 0 && !/^[A-Za-z0-9._-]{1,512}$/.test(options.capability)) return reportUsageError(options, "Supply --capability exactly as issued, without whitespace or unsupported characters, and at most 512 characters. The prepared attempt has not been consumed.");
|
|
2573
|
+
if (options.reviewDigest !== void 0 && !/^[0-9a-f]{64}$/.test(options.reviewDigest)) return reportUsageError(options, "Supply --review-digest as the exact 64-character lowercase hexadecimal digest from --prepare. Enrollment has not started.");
|
|
2574
|
+
return environmentUsage(options);
|
|
2575
|
+
}
|
|
2576
|
+
function environmentUsage(options) {
|
|
2577
|
+
const overrides = [
|
|
2578
|
+
"CODEMEM_SYNC_COORDINATOR_URL",
|
|
2579
|
+
"CODEMEM_SYNC_COORDINATOR_GROUP",
|
|
2580
|
+
"CODEMEM_SYNC_COORDINATOR_GROUPS",
|
|
2581
|
+
"CODEMEM_WORKSPACE_ID",
|
|
2582
|
+
"CODEMEM_ACTOR_ID",
|
|
2583
|
+
"CODEMEM_DEVICE_ID",
|
|
2584
|
+
"CODEMEM_ACTOR_DISPLAY_NAME"
|
|
2585
|
+
].filter((name) => process.env[name] !== void 0);
|
|
2586
|
+
if (overrides.length) return reportUsageError(options, `Unset ${overrides.join(" and ")} before owner enrollment. Use --coordinator and the group argument, or the selected config; environment overrides cannot be adopted safely.`);
|
|
2587
|
+
if (process.env.CODEMEM_SYNC_KEY_STORE?.trim().toLowerCase() === "keychain") return reportUsageError(options, "CODEMEM_SYNC_KEY_STORE=keychain is not supported for owner enrollment. Keep signing keys unchanged; select a supported existing file-key profile.");
|
|
2588
|
+
return true;
|
|
2589
|
+
}
|
|
2590
|
+
function coordinatorOriginUsage(options, value) {
|
|
2591
|
+
if (typeof value !== "string") return reportUsageError(options, "Set sync_coordinator_url to a valid HTTPS origin string before owner enrollment.");
|
|
2592
|
+
try {
|
|
2593
|
+
const url = new URL(value);
|
|
2594
|
+
if (value.trim() === value && url.protocol === "https:" && !url.username && !url.password && (value === url.origin || value === `${url.origin}/`)) return true;
|
|
2595
|
+
} catch {}
|
|
2596
|
+
return reportUsageError(options, "Set --coordinator or sync_coordinator_url to an HTTPS origin without credentials, paths, query strings, fragments, or surrounding whitespace.");
|
|
2597
|
+
}
|
|
2598
|
+
function groupUsage(options, groupId) {
|
|
2599
|
+
if (groupId.length > 0 && groupId.length <= 256 && groupId.trim() === groupId && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(groupId)) return true;
|
|
2600
|
+
return reportUsageError(options, "Use a nonempty group ID of at most 256 characters, without surrounding whitespace or control characters.");
|
|
2601
|
+
}
|
|
2602
|
+
function pathEnvironmentUsage(options, selected) {
|
|
2603
|
+
for (const [name, path, flag] of [
|
|
2604
|
+
[
|
|
2605
|
+
"CODEMEM_CONFIG",
|
|
2606
|
+
selected.config,
|
|
2607
|
+
"--config"
|
|
2608
|
+
],
|
|
2609
|
+
[
|
|
2610
|
+
"CODEMEM_DB",
|
|
2611
|
+
selected.db,
|
|
2612
|
+
"--db-path"
|
|
2613
|
+
],
|
|
2614
|
+
[
|
|
2615
|
+
"CODEMEM_KEYS_DIR",
|
|
2616
|
+
selected.keys,
|
|
2617
|
+
"--keys-dir"
|
|
2618
|
+
]
|
|
2619
|
+
]) {
|
|
2620
|
+
const selectedPath = normalizeEnrollmentPath(name, path);
|
|
2621
|
+
const override = normalizeEnrollmentPath(name, name === "CODEMEM_KEYS_DIR" ? process.env[name]?.trim() || void 0 : process.env[name]);
|
|
2622
|
+
if (selectedPath !== void 0 && override !== void 0 && resolve(selectedPath) !== resolve(override)) return reportUsageError(options, `Unset ${name} or make it match ${flag} before owner enrollment. Conflicting profile paths cannot be adopted safely.`);
|
|
2623
|
+
}
|
|
2624
|
+
return true;
|
|
2625
|
+
}
|
|
2626
|
+
function normalizeEnrollmentPath(name, path) {
|
|
2627
|
+
if (path === void 0) return void 0;
|
|
2628
|
+
if (name === "CODEMEM_KEYS_DIR") return path;
|
|
2629
|
+
const value = name === "CODEMEM_CONFIG" ? path.trim() : path;
|
|
2630
|
+
return value ? expandUserPath(value) : void 0;
|
|
2631
|
+
}
|
|
2632
|
+
function resolveEnrollmentProfile(options) {
|
|
2633
|
+
const configPath = resolveCodememConfigPath(options.config, "write").resolved.path;
|
|
2634
|
+
const config = readCodememConfigFileAtPath(configPath);
|
|
2635
|
+
if (config.sync_coordinator_url !== void 0 && !coordinatorOriginUsage(options, config.sync_coordinator_url)) return null;
|
|
2636
|
+
const coordinatorUrl = options.coordinator ?? readCoordinatorSyncConfig(config).syncCoordinatorUrl;
|
|
2637
|
+
if (!coordinatorUrl) {
|
|
2638
|
+
reportUsageError(options, "Set --coordinator to your coordinator HTTPS URL, or set sync_coordinator_url in the selected config.");
|
|
2639
|
+
return null;
|
|
2640
|
+
}
|
|
2641
|
+
if (!coordinatorOriginUsage(options, coordinatorUrl)) return null;
|
|
2642
|
+
const dbPath = resolveDbPath(resolveDbOpt(options));
|
|
2643
|
+
const keysDir = options.keysDir ?? (process.env.CODEMEM_KEYS_DIR?.trim() || void 0) ?? dirname(resolveKeyPaths()[0]);
|
|
2644
|
+
if (!pathEnvironmentUsage(options, {
|
|
2645
|
+
config: configPath,
|
|
2646
|
+
db: dbPath,
|
|
2647
|
+
keys: keysDir
|
|
2648
|
+
})) return null;
|
|
2649
|
+
return {
|
|
2650
|
+
configPath,
|
|
2651
|
+
coordinatorUrl,
|
|
2652
|
+
dbPath,
|
|
2653
|
+
keysDir
|
|
2654
|
+
};
|
|
2655
|
+
}
|
|
2656
|
+
async function enroll(groupId, options) {
|
|
2657
|
+
if (!usage(options)) return;
|
|
2658
|
+
if (!groupUsage(options, groupId)) return;
|
|
2659
|
+
if (!pathEnvironmentUsage(options, {
|
|
2660
|
+
config: options.config,
|
|
2661
|
+
db: resolveDbOpt(options),
|
|
2662
|
+
keys: options.keysDir
|
|
2663
|
+
})) return;
|
|
2664
|
+
const controller = new AbortController();
|
|
2665
|
+
const stop = () => controller.abort();
|
|
2666
|
+
process.once("SIGINT", stop);
|
|
2667
|
+
process.once("SIGTERM", stop);
|
|
2668
|
+
try {
|
|
2669
|
+
if (!options.json) p.log.warn(CAPTURE_WARNING, { output: process.stderr });
|
|
2670
|
+
const profile = resolveEnrollmentProfile(options);
|
|
2671
|
+
if (!profile) return;
|
|
2672
|
+
showResult(await enrollOwnerProfile({
|
|
2673
|
+
...profile,
|
|
2674
|
+
groupId,
|
|
2675
|
+
stateDir: options.stateDir ?? join(dirname(profile.dbPath), "owner-enrollment"),
|
|
2676
|
+
mode: options.prepare ? "prepare" : "resume",
|
|
2677
|
+
...admissionOptions(options),
|
|
2678
|
+
capability: options.capability,
|
|
2679
|
+
reviewedDigest: options.reviewDigest,
|
|
2680
|
+
signal: controller.signal
|
|
2681
|
+
}), options);
|
|
2682
|
+
} catch (error) {
|
|
2683
|
+
const safe = error instanceof OwnerProfileEnrollmentError ? new OwnerProfileEnrollmentError(error.code) : new OwnerProfileEnrollmentError("review_required");
|
|
2684
|
+
const message = safe.message;
|
|
2685
|
+
if (options.json) emitJsonError(safe.code, message);
|
|
2686
|
+
else {
|
|
2687
|
+
p.log.error(message, { output: process.stderr });
|
|
2688
|
+
process.exitCode = 1;
|
|
2689
|
+
}
|
|
2690
|
+
} finally {
|
|
2691
|
+
process.removeListener("SIGINT", stop);
|
|
2692
|
+
process.removeListener("SIGTERM", stop);
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
function showResult(result, options) {
|
|
2696
|
+
if (options.json) {
|
|
2697
|
+
process.stdout.write(`${JSON.stringify({
|
|
2698
|
+
...result,
|
|
2699
|
+
warning: CAPTURE_WARNING
|
|
2700
|
+
})}\n`);
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
if (result.kind === "prepared") {
|
|
2704
|
+
p.log.message(`Current Identity: ${result.review.actorId}\nDevice: ${result.review.deviceId}\nPublic key: ${result.review.publicKey}\nCapture: ${result.review.captureId ?? "none"} (${result.review.captureAssociation})\nOwn local memories: ${result.review.localMemoryCount}\nForeign rows retained without new access: ${result.review.retainedForeignMemoryCount}\nReview digest: ${result.reviewDigest}`, { output: process.stderr });
|
|
2705
|
+
p.log.message(`Private request file for operator admission or upload during --resume --account-admission: ${result.requestFile}`, { output: process.stderr });
|
|
2706
|
+
p.log.warn("Only the current Identity/device assignment will change. Signing keys, memory authorship and history stay unchanged; no membership is inferred.", { output: process.stderr });
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2709
|
+
p.log.success(`Verified coordinator Identity: ${result.identityId}\nOwner binding: ${result.bindingId}`, { output: process.stderr });
|
|
2710
|
+
}
|
|
2711
|
+
function buildCoordinatorEnrollOwnerCommand() {
|
|
2712
|
+
const command = new Command("enroll-owner").configureHelp(helpStyle).description("Review and enroll this existing profile's device owner").argument("<group>", "group id").option("-u, --coordinator <url>", "coordinator HTTPS origin (defaults to config)").option("--keys-dir <path>", "existing signing-key directory").option("-s, --state-dir <path>", "private retained-request directory").option("--prepare", "save a reviewed request for operator admission").option("--resume", "resume the saved request or config-only adoption").option("--capability <token>", "admin-issued capability for this exact start").option("--account-admission", "use signed-in account admission with --resume only").option("-v, --review-digest <digest>", "explicitly approve the prepared profile review");
|
|
2713
|
+
addDbOption(command);
|
|
2714
|
+
addConfigOption(command);
|
|
2715
|
+
addJsonOption(command);
|
|
2716
|
+
command.action(enroll);
|
|
2717
|
+
return command;
|
|
2718
|
+
}
|
|
2719
|
+
//#endregion
|
|
2720
|
+
//#region src/commands/coordinator-grant-identity-group.ts
|
|
2721
|
+
function failure$3(options, message, exitCode = 1) {
|
|
2722
|
+
if (options.json) {
|
|
2723
|
+
emitJsonError("identity_group_grant_unavailable", message, exitCode);
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
p.log.error(message, { output: process.stderr });
|
|
2727
|
+
process.exitCode = exitCode;
|
|
2728
|
+
}
|
|
2729
|
+
function grantErrorMessage(error) {
|
|
2730
|
+
if (error instanceof RemoteCoordinatorRequestError) {
|
|
2731
|
+
if (error.status === 401 || error.status === 403) return "Coordinator refused the configured admin credential. Ask the operator to check it; do not pass credentials on the command line.";
|
|
2732
|
+
if (error.code === "invalid_grant_input") return "Coordinator rejected the reference. Use the stored controller attestation reference returned by device-owner review.";
|
|
2733
|
+
if (error.code === "grant_authority_unavailable") return "The stored review cannot authorize this grant. Ask the operator to check current enrollment, review revocation, and group status; do not edit the database.";
|
|
2734
|
+
if (error.status === 429 || error.code === "rate_limited") return "Coordinator rate limit reached. Wait before previewing and confirming again.";
|
|
2735
|
+
if (error.status === 404) return "This coordinator does not support the grant endpoint. Ask the operator to check its deployment.";
|
|
2736
|
+
}
|
|
2737
|
+
return "Transport grant could not be confirmed. Its outcome may be unknown; check coordinator setup and the stored review before retrying. No local profile was changed.";
|
|
2738
|
+
}
|
|
2739
|
+
function configuredTarget(options) {
|
|
2740
|
+
const config = options.config ? readCodememConfigFileAtPath(options.config) : readCodememConfigFile();
|
|
2741
|
+
const sync = readCoordinatorSyncConfig(config);
|
|
2742
|
+
return {
|
|
2743
|
+
remoteUrl: normalizeCoordinatorAccountLinkOrigin(sync.syncCoordinatorUrl),
|
|
2744
|
+
adminSecret: sync.syncCoordinatorAdminSecret,
|
|
2745
|
+
timeoutS: Math.max(1, sync.syncCoordinatorTimeoutS)
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
async function confirmGrant(request, options) {
|
|
2749
|
+
const approved = await p.confirm({
|
|
2750
|
+
message: "Issue the reviewed Identity's transport enrollment grant?",
|
|
2751
|
+
initialValue: false,
|
|
2752
|
+
output: process.stderr
|
|
2753
|
+
});
|
|
2754
|
+
if (p.isCancel(approved) || approved !== true) {
|
|
2755
|
+
p.log.info("Nothing changed.", { output: process.stderr });
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
const current = configuredTarget(options);
|
|
2759
|
+
if (current.remoteUrl !== request.remoteUrl || current.adminSecret !== request.adminSecret) return failure$3(options, "Coordinator configuration changed. Nothing changed; preview again.");
|
|
2760
|
+
const result = await coordinatorGrantIdentityGroupAction(request);
|
|
2761
|
+
p.log.success(`Transport enrollment grant ${result.kind}: coordinator ${result.grant.coordinator_id}; Identity ${result.grant.identity_id}; group ${result.grant.group_id}. No Team/Project access or local profile changes.`, { output: process.stderr });
|
|
2762
|
+
}
|
|
2763
|
+
async function grantIdentityGroup(attestationId, options) {
|
|
2764
|
+
try {
|
|
2765
|
+
if (!isCoordinatorControllerAttestationReference(attestationId)) return failure$3(options, "Use a stored controller-v1: reference followed by exactly 64 lowercase hexadecimal characters.", 2);
|
|
2766
|
+
const target = configuredTarget(options);
|
|
2767
|
+
if (!target.remoteUrl) return failure$3(options, "Configure a coordinator HTTPS origin without credentials, path, query, or fragment (HTTP only on literal loopback). Nothing changed.", 2);
|
|
2768
|
+
if (!target.adminSecret) return failure$3(options, "Ask the coordinator operator to configure the existing admin credential. Nothing changed.");
|
|
2769
|
+
const preview = {
|
|
2770
|
+
state: "preview",
|
|
2771
|
+
coordinator_url: target.remoteUrl,
|
|
2772
|
+
attestation_id: attestationId,
|
|
2773
|
+
unchanged: true,
|
|
2774
|
+
eligibility_checked: false,
|
|
2775
|
+
message: "Explicit transport enrollment grant only, not Team/Project access. The server derives Identity and group from the stored review. No Google flow or local profile changes. Run in a terminal without --json to confirm."
|
|
2776
|
+
};
|
|
2777
|
+
if (options.json) {
|
|
2778
|
+
process.stdout.write(`${JSON.stringify(preview)}\n`);
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
p.log.info(`Coordinator: ${target.remoteUrl}\nController attestation: ${attestationId}\n${preview.message}\nThis is a local request preview, not a check of server eligibility.`, { output: process.stderr });
|
|
2782
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) return;
|
|
2783
|
+
await confirmGrant({
|
|
2784
|
+
...target,
|
|
2785
|
+
remoteUrl: target.remoteUrl,
|
|
2786
|
+
attestationId
|
|
2787
|
+
}, options);
|
|
2788
|
+
} catch (error) {
|
|
2789
|
+
failure$3(options, grantErrorMessage(error));
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
function buildCoordinatorGrantIdentityGroupCommand() {
|
|
2793
|
+
const command = new Command("grant-identity-group").configureHelp(helpStyle).description("Preview and confirm an explicit transport enrollment grant").argument("<attestation-id>", "stored controller attestation reference");
|
|
2794
|
+
addConfigOption(command);
|
|
2795
|
+
addDbOption(command);
|
|
2796
|
+
addJsonOption(command);
|
|
2797
|
+
command.action(grantIdentityGroup);
|
|
2798
|
+
return command;
|
|
2799
|
+
}
|
|
2800
|
+
//#endregion
|
|
2801
|
+
//#region src/commands/coordinator-link-account.ts
|
|
2802
|
+
function reportLinkFailure(error, coordinatorOrigin) {
|
|
2803
|
+
const message = error instanceof CoordinatorAccountLinkError ? error.message : "Account linking could not finish. Check your existing device setup and coordinator configuration, then try again.";
|
|
2804
|
+
p.log.error(message, { output: process.stderr });
|
|
2805
|
+
if (!(error instanceof CoordinatorAccountLinkError)) return;
|
|
2806
|
+
if (error.code !== "link_conflict") return;
|
|
2807
|
+
let guidance = "Replacing or removing coordinator account links is not supported. ";
|
|
2808
|
+
if (coordinatorOrigin) {
|
|
2809
|
+
const signInUrl = new URL("/auth/sign-in", coordinatorOrigin).href;
|
|
2810
|
+
guidance += `If this Google account already belongs to the intended coordinator Identity, sign in at ${signInUrl}. Otherwise, ask your coordinator operator.`;
|
|
2811
|
+
} else guidance += "Ask your coordinator operator.";
|
|
2812
|
+
p.log.message(guidance, { output: process.stderr });
|
|
2813
|
+
}
|
|
2814
|
+
function showPrivateBrowserLink(privateUrl) {
|
|
2815
|
+
p.log.warn("Keep this browser link private. Open it to review account linking, then return to the coordinator tab to finish.", { output: process.stderr });
|
|
2816
|
+
p.log.message(privateUrl, { output: process.stderr });
|
|
2817
|
+
}
|
|
2818
|
+
function requireInteractive(options) {
|
|
2819
|
+
if (options.json) {
|
|
2820
|
+
emitJsonError("interactive_flow_only", "Account linking is interactive. Run without --json and keep the browser link private.", 2);
|
|
2821
|
+
return false;
|
|
2822
|
+
}
|
|
2823
|
+
if (!process.stderr.isTTY) {
|
|
2824
|
+
p.log.error("Account linking requires a terminal. Run this command interactively and keep the browser link private.", { output: process.stderr });
|
|
2825
|
+
process.exitCode = 2;
|
|
2826
|
+
return false;
|
|
2827
|
+
}
|
|
2828
|
+
return true;
|
|
2829
|
+
}
|
|
2830
|
+
function readLinkConfiguration(options) {
|
|
2831
|
+
const config = options.config ? readCodememConfigFileAtPath(options.config) : readCodememConfigFile();
|
|
2832
|
+
const coordinatorUrl = normalizeCoordinatorAccountLinkOrigin(options.coordinator ?? readCoordinatorSyncConfig(config).syncCoordinatorUrl);
|
|
2833
|
+
if (!coordinatorUrl || options.loopbackHost !== "127.0.0.1" && options.loopbackHost !== "::1") return null;
|
|
2834
|
+
return {
|
|
2835
|
+
config,
|
|
2836
|
+
coordinatorUrl,
|
|
2837
|
+
loopbackHost: options.loopbackHost
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
function readLinkProfile(options, selected) {
|
|
2841
|
+
try {
|
|
2842
|
+
const dbPath = resolveDbPath(resolveDbOpt(options));
|
|
2843
|
+
const before = statSync(dbPath, { bigint: true });
|
|
2844
|
+
const device = readAccountLinkDevice(dbPath);
|
|
2845
|
+
const after = statSync(dbPath, { bigint: true });
|
|
2846
|
+
const override = process.env.CODEMEM_DEVICE_ID?.trim() || void 0;
|
|
2847
|
+
if (!before.isFile() || before.dev !== after.dev || before.ino !== after.ino || override !== void 0 && override !== device.deviceId) throw new CoordinatorAccountLinkError("device_unavailable");
|
|
2848
|
+
let actor = selected.config.actor_id;
|
|
2849
|
+
if (Object.hasOwn(process.env, "CODEMEM_ACTOR_ID")) actor = process.env.CODEMEM_ACTOR_ID;
|
|
2850
|
+
const identityId = (typeof actor === "string" ? actor.trim() : "") || `local:${device.deviceId}`;
|
|
2851
|
+
return {
|
|
2852
|
+
dbPath,
|
|
2853
|
+
keysDir: process.env.CODEMEM_KEYS_DIR?.trim() || void 0,
|
|
2854
|
+
coordinatorUrl: selected.coordinatorUrl,
|
|
2855
|
+
expectedIdentityId: identityId,
|
|
2856
|
+
deviceId: device.deviceId,
|
|
2857
|
+
fingerprint: device.fingerprint,
|
|
2858
|
+
fileIdentity: {
|
|
2859
|
+
dev: after.dev.toString(),
|
|
2860
|
+
ino: after.ino.toString()
|
|
2861
|
+
}
|
|
2862
|
+
};
|
|
2863
|
+
} catch {
|
|
2864
|
+
throw new CoordinatorAccountLinkError("device_unavailable");
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
function profileMatches(options, snapshot) {
|
|
2868
|
+
const selected = readLinkConfiguration(options);
|
|
2869
|
+
return selected !== null && JSON.stringify(readLinkProfile(options, selected)) === snapshot;
|
|
2870
|
+
}
|
|
2871
|
+
function eligibleStatus(remote, identityId) {
|
|
2872
|
+
if (remote.kind !== "status") {
|
|
2873
|
+
if (remote.kind === "unavailable" && remote.code === "coordinator_auth_schema_upgrade_required") throw new CoordinatorAccountLinkError("schema_upgrade_required");
|
|
2874
|
+
throw new CoordinatorAccountLinkError("invalid_response");
|
|
2875
|
+
}
|
|
2876
|
+
const status = remote.status;
|
|
2877
|
+
if (status.state === "conflict" || status.state === "revoked") throw new CoordinatorAccountLinkError("link_conflict");
|
|
2878
|
+
if (status.state === "review_required") throw new CoordinatorAccountLinkError("review_required");
|
|
2879
|
+
if (status.identityId !== identityId) throw new CoordinatorAccountLinkError("identity_mismatch");
|
|
2880
|
+
return status;
|
|
2881
|
+
}
|
|
2882
|
+
async function linkAccount(group, options) {
|
|
2883
|
+
if (!requireInteractive(options)) return;
|
|
2884
|
+
const controller = new AbortController();
|
|
2885
|
+
const stop = () => controller.abort();
|
|
2886
|
+
process.once("SIGINT", stop);
|
|
2887
|
+
process.once("SIGTERM", stop);
|
|
2888
|
+
let coordinatorOrigin = null;
|
|
2889
|
+
try {
|
|
2890
|
+
const selected = readLinkConfiguration(options);
|
|
2891
|
+
if (!selected) {
|
|
2892
|
+
p.log.error("Set --coordinator to your coordinator HTTPS origin and use --loopback-host 127.0.0.1 or ::1.", { output: process.stderr });
|
|
2893
|
+
process.exitCode = 2;
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2896
|
+
const { coordinatorUrl } = selected;
|
|
2897
|
+
coordinatorOrigin = coordinatorUrl;
|
|
2898
|
+
const profile = readLinkProfile(options, selected);
|
|
2899
|
+
const status = eligibleStatus(await readCoordinatorAccountLinkStatus({
|
|
2900
|
+
...profile,
|
|
2901
|
+
coordinatorUrl,
|
|
2902
|
+
identityId: profile.expectedIdentityId,
|
|
2903
|
+
groupId: group,
|
|
2904
|
+
signal: controller.signal
|
|
2905
|
+
}), profile.expectedIdentityId);
|
|
2906
|
+
if (status.state === "linked") {
|
|
2907
|
+
p.log.success("This Identity is already linked. No account-link request was started.", { output: process.stderr });
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
const snapshot = JSON.stringify(profile);
|
|
2911
|
+
const result = await linkCoordinatorAccount({
|
|
2912
|
+
...profile,
|
|
2913
|
+
groupId: group,
|
|
2914
|
+
coordinatorUrl,
|
|
2915
|
+
expectedAuthority: status.authority,
|
|
2916
|
+
expectedCoordinatorId: status.coordinatorId,
|
|
2917
|
+
assertCurrentContext: () => profileMatches(options, snapshot),
|
|
2918
|
+
loopbackHost: selected.loopbackHost,
|
|
2919
|
+
signal: controller.signal,
|
|
2920
|
+
onBrowserStart: showPrivateBrowserLink
|
|
2921
|
+
});
|
|
2922
|
+
p.log.success(`Coordinator account linked to coordinator Identity ${result.identityId}. Your local Identity has not changed.`, { output: process.stderr });
|
|
2923
|
+
} catch (error) {
|
|
2924
|
+
reportLinkFailure(error, coordinatorOrigin);
|
|
2925
|
+
process.exitCode = 1;
|
|
2926
|
+
} finally {
|
|
2927
|
+
process.removeListener("SIGINT", stop);
|
|
2928
|
+
process.removeListener("SIGTERM", stop);
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
function buildCoordinatorLinkAccountCommand() {
|
|
2932
|
+
const command = new Command("link-account").configureHelp(helpStyle).description("Link a coordinator account to your existing Identity").argument("<group>", "group id").option("-u, --coordinator <url>", "coordinator HTTPS origin (defaults to saved config)").option("-l, --loopback-host <host>", "literal loopback host", "127.0.0.1");
|
|
2933
|
+
addDbOption(command);
|
|
2934
|
+
addConfigOption(command);
|
|
2935
|
+
addJsonOption(command);
|
|
2936
|
+
command.action(linkAccount);
|
|
2937
|
+
return command;
|
|
2938
|
+
}
|
|
2939
|
+
//#endregion
|
|
2940
|
+
//#region src/commands/coordinator-qualify-owner.ts
|
|
2941
|
+
var isAttestation = (value) => typeof value === "string" && /^controller-v1:[a-f0-9]{64}$/u.test(value);
|
|
2942
|
+
var isHash$1 = (value) => typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
|
2943
|
+
function isRef(value) {
|
|
2944
|
+
return typeof value === "string" && value.length > 0 && value.length <= 256 && value.trim() === value && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(value);
|
|
2945
|
+
}
|
|
2946
|
+
function setup$1(options) {
|
|
2947
|
+
const config = options.config ? readCodememConfigFileAtPath(options.config) : readCodememConfigFile();
|
|
2948
|
+
const sync = readCoordinatorSyncConfig(config);
|
|
2949
|
+
const remoteUrl = normalizeCoordinatorAccountLinkOrigin(sync.syncCoordinatorUrl);
|
|
2950
|
+
if (!remoteUrl || !sync.syncCoordinatorAdminSecret) throw new Error("configuration_unavailable");
|
|
2951
|
+
return {
|
|
2952
|
+
remoteUrl,
|
|
2953
|
+
adminSecret: sync.syncCoordinatorAdminSecret,
|
|
2954
|
+
timeoutS: Math.max(1, sync.syncCoordinatorTimeoutS)
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
function parsePreview$1(reply, attestationId) {
|
|
2958
|
+
if (reply.state !== "ready" || reply.attestation_id !== attestationId || !isHash$1(reply.evidence_digest) || !isHash$1(reply.fingerprint) || ![
|
|
2959
|
+
"coordinator_id",
|
|
2960
|
+
"review_receipt_id",
|
|
2961
|
+
"link_id",
|
|
2962
|
+
"claim_id",
|
|
2963
|
+
"identity_id",
|
|
2964
|
+
"group_id",
|
|
2965
|
+
"device_id"
|
|
2966
|
+
].every((key) => isRef(reply[key]))) throw new Error("invalid_preview");
|
|
2967
|
+
return {
|
|
2968
|
+
state: "ready",
|
|
2969
|
+
attestation_id: attestationId,
|
|
2970
|
+
evidence_digest: reply.evidence_digest,
|
|
2971
|
+
coordinator_id: reply.coordinator_id,
|
|
2972
|
+
review_receipt_id: reply.review_receipt_id,
|
|
2973
|
+
link_id: reply.link_id,
|
|
2974
|
+
claim_id: reply.claim_id,
|
|
2975
|
+
identity_id: reply.identity_id,
|
|
2976
|
+
group_id: reply.group_id,
|
|
2977
|
+
device_id: reply.device_id,
|
|
2978
|
+
fingerprint: reply.fingerprint
|
|
2979
|
+
};
|
|
2980
|
+
}
|
|
2981
|
+
var QUALIFICATION_DENIAL_CODES = [
|
|
2982
|
+
"invalid_input",
|
|
2983
|
+
"preview_changed",
|
|
2984
|
+
"qualification_conflict",
|
|
2985
|
+
"unavailable"
|
|
2986
|
+
];
|
|
2987
|
+
function failure$2(options, error, message, exitCode = 1) {
|
|
2988
|
+
if (options.json) return emitJsonError(error, message, exitCode);
|
|
2989
|
+
p.log.error(message, { output: process.stderr });
|
|
2990
|
+
process.exitCode = exitCode;
|
|
2991
|
+
}
|
|
2992
|
+
function sameConfiguration$1(options, request) {
|
|
2993
|
+
try {
|
|
2994
|
+
const current = setup$1(options);
|
|
2995
|
+
return current.remoteUrl === request.remoteUrl && current.adminSecret === request.adminSecret;
|
|
2996
|
+
} catch {
|
|
2997
|
+
return false;
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
async function confirmQualification(options, request, preview, stage) {
|
|
3001
|
+
const approved = await p.confirm({
|
|
3002
|
+
message: "Allow this linked account to enroll new devices under this Identity?",
|
|
3003
|
+
initialValue: false,
|
|
3004
|
+
output: process.stderr
|
|
3005
|
+
});
|
|
3006
|
+
if (p.isCancel(approved) || !approved) {
|
|
3007
|
+
p.log.info("Nothing changed.", { output: process.stderr });
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
if (!sameConfiguration$1(options, request)) return failure$2(options, "qualification_configuration_changed", "Operator configuration changed. Preview again before confirming.");
|
|
3011
|
+
stage.onDispatch();
|
|
3012
|
+
const result = await coordinatorQualifyOwnerAction({
|
|
3013
|
+
...request,
|
|
3014
|
+
confirmEvidenceDigest: preview.evidence_digest
|
|
3015
|
+
});
|
|
3016
|
+
if (result.state !== "created" && result.state !== "existing" || !isRef(result.qualification_id) || result.attestation_id !== request.attestationId || result.coordinator_id !== preview.coordinator_id || result.evidence_digest !== preview.evidence_digest) throw new Error("invalid_commit_response");
|
|
3017
|
+
p.log.success("Linked account qualified for device enrollment. Team and Project access are unchanged.", { output: process.stderr });
|
|
3018
|
+
}
|
|
3019
|
+
async function qualifyOwner(attestationId, options) {
|
|
3020
|
+
let writeAttempted = false;
|
|
3021
|
+
try {
|
|
3022
|
+
if (!isAttestation(attestationId)) return failure$2(options, "qualification_invalid_input", "Use the exact controller-v1 reference from a real device-owner review.", 2);
|
|
3023
|
+
const request = {
|
|
3024
|
+
...setup$1(options),
|
|
3025
|
+
attestationId
|
|
3026
|
+
};
|
|
3027
|
+
const preview = parsePreview$1(await coordinatorQualifyOwnerAction(request), attestationId);
|
|
3028
|
+
if (options.json) {
|
|
3029
|
+
process.stdout.write(`${JSON.stringify({
|
|
3030
|
+
preview,
|
|
3031
|
+
network_request: true,
|
|
3032
|
+
unchanged: true
|
|
3033
|
+
})}\n`);
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
p.log.info(`Network preview from ${preview.coordinator_id}\nIdentity: ${preview.identity_id}\nLinked account reference: ${preview.link_id}\nDevice: ${preview.device_id}; fingerprint: ${preview.fingerprint}\nReview: ${preview.attestation_id}\nReceipt: ${preview.review_receipt_id}\nClaim: ${preview.claim_id}; group: ${preview.group_id}`, { output: process.stderr });
|
|
3037
|
+
p.log.warn("Independently verify the real operator review. Qualification permits new devices under this Identity; an independently trusted transport-group grant is still separate. This does not create a Team, issue a grant, or rewrite a device claim.", { output: process.stderr });
|
|
3038
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
3039
|
+
p.log.info("Preview only; no qualification issued. Use a terminal without --json to confirm.", { output: process.stderr });
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
await confirmQualification(options, request, preview, { onDispatch: () => {
|
|
3043
|
+
writeAttempted = true;
|
|
3044
|
+
} });
|
|
3045
|
+
} catch (error) {
|
|
3046
|
+
if (error instanceof RemoteCoordinatorRequestError && error.status === 409 && QUALIFICATION_DENIAL_CODES.includes(error.code)) return failure$2(options, "qualification_rejected", "Qualification denied: evidence is invalid, changed, was revoked, conflicts, or is unavailable. Preview again; no automatic retry.");
|
|
3047
|
+
let message = "Qualification server unavailable or preview invalid. Ask the coordinator operator to check the configured Worker's browser authentication and owner-enrollment settings, required migrations 0035 and 0036, and the saved trusted HTTPS coordinator origin and admin credential.";
|
|
3048
|
+
if (writeAttempted) message = "Qualification outcome is unknown. It may have committed; do not assume rollback or retry automatically. Ask the coordinator operator to inspect the outcome.";
|
|
3049
|
+
failure$2(options, writeAttempted ? "qualification_outcome_unknown" : "qualification_unavailable", message);
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
function buildCoordinatorQualifyOwnerCommand() {
|
|
3053
|
+
const command = new Command("qualify-owner").configureHelp(helpStyle).description("Preview and confirm linked-account device enrollment permission").argument("<attestation-id>", "controller-v1 reference from a real operator review");
|
|
3054
|
+
addConfigOption(command);
|
|
3055
|
+
addJsonOption(command);
|
|
3056
|
+
command.action(qualifyOwner);
|
|
3057
|
+
return command;
|
|
3058
|
+
}
|
|
3059
|
+
//#endregion
|
|
3060
|
+
//#region src/commands/coordinator-review-device-owner.ts
|
|
3061
|
+
var REVIEW_REASONS = [
|
|
3062
|
+
"group_unavailable",
|
|
3063
|
+
"enrollment_unavailable",
|
|
3064
|
+
"key_mismatch",
|
|
3065
|
+
"enrollment_identity_mismatch",
|
|
3066
|
+
"invite_identity_mismatch",
|
|
3067
|
+
"invite_evidence_invalid"
|
|
3068
|
+
];
|
|
3069
|
+
function validId$1(value) {
|
|
3070
|
+
return typeof value === "string" && value.length > 0 && value.length <= 256 && value.trim() === value && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(value);
|
|
3071
|
+
}
|
|
3072
|
+
function isHash(value) {
|
|
3073
|
+
return typeof value === "string" && value.length === 64 && /^[a-f0-9]{64}$/.test(value);
|
|
3074
|
+
}
|
|
3075
|
+
function reviewErrorMessage(error) {
|
|
3076
|
+
if (error instanceof RemoteCoordinatorRequestError) {
|
|
3077
|
+
if (error.code === "review_stale") return "Evidence changed since the preview. Nothing changed; run the review again.";
|
|
3078
|
+
if (error.code === "already_reviewed_or_needs_review") return "This device already has a reviewed owner, or needs a separate review. Nothing changed.";
|
|
3079
|
+
if (error.code === "needs_review") return "Coordinator ownership evidence needs review. Nothing changed; ask the coordinator operator.";
|
|
3080
|
+
}
|
|
3081
|
+
return "Device owner review could not finish. Check the existing device, admin credential, and coordinator setup; preview again before retrying.";
|
|
3082
|
+
}
|
|
3083
|
+
function failure$1(options, message, exitCode = 1) {
|
|
3084
|
+
if (options.json) {
|
|
3085
|
+
emitJsonError("owner_review_unavailable", message, exitCode);
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3088
|
+
p.log.error(message, { output: process.stderr });
|
|
3089
|
+
process.exitCode = exitCode;
|
|
3090
|
+
}
|
|
3091
|
+
function localOptions(options) {
|
|
3092
|
+
const config = options.config ? readCodememConfigFileAtPath(options.config) : readCodememConfigFile();
|
|
3093
|
+
const sync = readCoordinatorSyncConfig(config);
|
|
3094
|
+
let actorId;
|
|
3095
|
+
let identitySource = "config";
|
|
3096
|
+
if (Object.hasOwn(process.env, "CODEMEM_ACTOR_ID")) {
|
|
3097
|
+
actorId = process.env.CODEMEM_ACTOR_ID?.trim();
|
|
3098
|
+
identitySource = "env";
|
|
3099
|
+
} else if (typeof config.actor_id === "string") actorId = config.actor_id.trim();
|
|
3100
|
+
return {
|
|
3101
|
+
local: {
|
|
3102
|
+
dbPath: resolveDbPath(resolveDbOpt(options)),
|
|
3103
|
+
actorId,
|
|
3104
|
+
identitySource,
|
|
3105
|
+
deviceIdOverride: process.env.CODEMEM_DEVICE_ID?.trim()
|
|
3106
|
+
},
|
|
3107
|
+
remoteUrl: normalizeCoordinatorAccountLinkOrigin(options.coordinator ?? sync.syncCoordinatorUrl),
|
|
3108
|
+
adminSecret: sync.syncCoordinatorAdminSecret,
|
|
3109
|
+
timeoutS: Math.max(1, sync.syncCoordinatorTimeoutS)
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
function displayLocal(evidence) {
|
|
3113
|
+
const device = evidence.device;
|
|
3114
|
+
return {
|
|
3115
|
+
...evidence,
|
|
3116
|
+
device: device ? {
|
|
3117
|
+
deviceId: device.deviceId,
|
|
3118
|
+
fingerprint: device.fingerprint,
|
|
3119
|
+
label: device.label
|
|
3120
|
+
} : null
|
|
3121
|
+
};
|
|
3122
|
+
}
|
|
3123
|
+
function showLocal(evidence) {
|
|
3124
|
+
const count = (value) => value === null ? "unknown" : String(value);
|
|
3125
|
+
const device = evidence.device;
|
|
3126
|
+
const identity = evidence.identity;
|
|
3127
|
+
p.log.message([
|
|
3128
|
+
`Device: ${device?.label ?? device?.deviceId ?? "unavailable"}`,
|
|
3129
|
+
`Device ID: ${device?.deviceId ?? "unknown"}; fingerprint: ${device?.fingerprint ?? "unknown"}`,
|
|
3130
|
+
`Identity: ${identity?.label ?? identity?.identityId ?? "unavailable"} (${identity?.source ?? "unknown"})`,
|
|
3131
|
+
`Identity ID: ${identity?.identityId ?? "unknown"}`,
|
|
3132
|
+
`Memory authors: current ${count(evidence.memoryCounts.current)}, other ${count(evidence.memoryCounts.others)}, unknown ${count(evidence.memoryCounts.unknown)} — unchanged`,
|
|
3133
|
+
`Active Teams: ${count(evidence.teamCount)}; direct Projects: ${count(evidence.projectCount)} — unchanged`,
|
|
3134
|
+
"This review does not move memories or change access. Google account linking is a separate step."
|
|
3135
|
+
].join("\n"), { output: process.stderr });
|
|
3136
|
+
}
|
|
3137
|
+
function validInviteCount(reply, invites) {
|
|
3138
|
+
return invites.length <= 10 && Number.isSafeInteger(reply.reviewed_invite_count) && reply.reviewed_invite_count >= invites.length;
|
|
3139
|
+
}
|
|
3140
|
+
function validInviteRef(invite) {
|
|
3141
|
+
return !!invite && validId$1(invite.invite_id) && (invite.kind === "team_member" || invite.kind === "add_device");
|
|
3142
|
+
}
|
|
3143
|
+
function parsePreview(payload, request) {
|
|
3144
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("invalid_preview");
|
|
3145
|
+
const reply = payload;
|
|
3146
|
+
const enrollment = reply.enrollment;
|
|
3147
|
+
const invites = reply.reviewed_invites;
|
|
3148
|
+
if (reply.state !== "ready" && reply.state !== "needs_review" || !validId$1(reply.coordinator_id) || !Array.isArray(reply.reasons) || !reply.reasons.every((reason) => typeof reason === "string" && REVIEW_REASONS.includes(reason)) || !Array.isArray(invites) || !validInviteCount(reply, invites) || !invites.every(validInviteRef)) throw new Error("invalid_preview");
|
|
3149
|
+
if (enrollment !== null && (!enrollment || enrollment.device_id !== request.deviceId || !isHash(enrollment.fingerprint) || !["none", "matches"].includes(enrollment.identity_label))) throw new Error("invalid_preview");
|
|
3150
|
+
if (reply.state === "ready" && (!enrollment || enrollment.device_id !== request.deviceId || enrollment.fingerprint !== request.fingerprint || !["none", "matches"].includes(enrollment.identity_label) || !isHash(reply.evidence_digest) || reply.reasons.length)) throw new Error("invalid_preview");
|
|
3151
|
+
return {
|
|
3152
|
+
state: reply.state,
|
|
3153
|
+
coordinator_id: reply.coordinator_id,
|
|
3154
|
+
reasons: reply.reasons,
|
|
3155
|
+
evidence_digest: reply.state === "ready" ? reply.evidence_digest : void 0,
|
|
3156
|
+
enrollment: enrollment ? {
|
|
3157
|
+
device_id: enrollment.device_id,
|
|
3158
|
+
fingerprint: enrollment.fingerprint,
|
|
3159
|
+
display_name: null,
|
|
3160
|
+
identity_label: enrollment.identity_label
|
|
3161
|
+
} : null,
|
|
3162
|
+
reviewed_invites: invites.map((invite) => ({
|
|
3163
|
+
invite_id: invite.invite_id,
|
|
3164
|
+
kind: invite.kind
|
|
3165
|
+
})),
|
|
3166
|
+
reviewed_invite_count: reply.reviewed_invite_count
|
|
3167
|
+
};
|
|
3168
|
+
}
|
|
3169
|
+
function sameLocalOwner(before, after) {
|
|
3170
|
+
return after.state === "ready" && before.ownershipRecords.actorPresent === after.ownershipRecords.actorPresent && before.ownershipRecords.deviceAssignmentPresent === after.ownershipRecords.deviceAssignmentPresent && before.device?.deviceId === after.device?.deviceId && before.device?.publicKey === after.device?.publicKey && before.device?.fingerprint === after.device?.fingerprint && before.identity?.identityId === after.identity?.identityId && before.identity?.source === after.identity?.source;
|
|
3171
|
+
}
|
|
3172
|
+
async function confirmReview(group, options, evidence, request, preview) {
|
|
3173
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) return failure$1(options, "Preview only. Run in a terminal without --json to confirm this review.", 2);
|
|
3174
|
+
const approved = await p.confirm({
|
|
3175
|
+
message: "Review this device owner?",
|
|
3176
|
+
initialValue: false,
|
|
3177
|
+
output: process.stderr
|
|
3178
|
+
});
|
|
3179
|
+
if (p.isCancel(approved) || !approved) {
|
|
3180
|
+
p.log.info("Nothing changed.", { output: process.stderr });
|
|
3181
|
+
return;
|
|
3182
|
+
}
|
|
3183
|
+
const current = localOptions(options);
|
|
3184
|
+
if (current.remoteUrl !== request.remoteUrl || !sameLocalOwner(evidence, readCoordinatorOwnerReviewLocalEvidence(current.local))) return failure$1(options, "Your device or Identity changed. Nothing changed on the coordinator; preview again.");
|
|
3185
|
+
const result = await coordinatorAuthControllerReviewAction({
|
|
3186
|
+
...request,
|
|
3187
|
+
confirmEvidenceDigest: preview.evidence_digest
|
|
3188
|
+
});
|
|
3189
|
+
if (result.state !== "created" && result.state !== "existing" || typeof result.attestation_id !== "string" || result.attestation_id.length !== 78 || !/^controller-v1:[a-f0-9]{64}$/.test(result.attestation_id) || result.coordinator_id !== preview.coordinator_id || result.group_id !== group || result.device_id !== request.deviceId || result.identity_id !== request.identityId || result.fingerprint !== request.fingerprint || typeof result.created_at !== "string") throw new Error("invalid_commit_response");
|
|
3190
|
+
p.log.success("Device owner reviewed. Memories and access are unchanged.", { output: process.stderr });
|
|
3191
|
+
p.log.info(`Controller attestation reference: ${result.attestation_id}\nKeep this noncredential reference for a separate, explicit operator-issued transport group grant. This review does not issue that grant or grant Team/Project access.`, { output: process.stderr });
|
|
3192
|
+
if (showTargetedLinkFollowUp(options)) return;
|
|
3193
|
+
showLinkFollowUp(group);
|
|
3194
|
+
}
|
|
3195
|
+
function showLinkFollowUp(group) {
|
|
3196
|
+
if (group.includes("\\")) {
|
|
3197
|
+
p.log.info(`Next, run codemem coordinator link-account with group ID ${JSON.stringify(group)}. Quote the ID for your shell.`, { output: process.stderr });
|
|
3198
|
+
return;
|
|
3199
|
+
}
|
|
3200
|
+
const quotedGroup = `'${group.replaceAll("'", "'\\''")}'`;
|
|
3201
|
+
p.log.info(`Next, link your Google account separately: codemem coordinator link-account ${quotedGroup}`, { output: process.stderr });
|
|
3202
|
+
}
|
|
3203
|
+
function showTargetedLinkFollowUp(options) {
|
|
3204
|
+
if (!options.coordinator && !options.config && !resolveDbOpt(options)) return false;
|
|
3205
|
+
p.log.info("Next, link your Google account separately with codemem coordinator link-account for this group. Reuse the same --coordinator, --config, and --db-path options from this review.", { output: process.stderr });
|
|
3206
|
+
return true;
|
|
3207
|
+
}
|
|
3208
|
+
function showLocalStop(evidence, options) {
|
|
3209
|
+
if (options.json) {
|
|
3210
|
+
process.stdout.write(`${JSON.stringify({
|
|
3211
|
+
...displayLocal(evidence),
|
|
3212
|
+
unchanged: true,
|
|
3213
|
+
error: "needs_review",
|
|
3214
|
+
message: "Local device ownership needs review. Nothing changed."
|
|
3215
|
+
})}\n`);
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
p.log.warn(`Review stopped: ${evidence.reasons.join(", ")}. Nothing changed.`, { output: process.stderr });
|
|
3219
|
+
}
|
|
3220
|
+
function emitReviewPreview(evidence, preview) {
|
|
3221
|
+
let stopped = {};
|
|
3222
|
+
if (preview.state !== "ready") {
|
|
3223
|
+
stopped = {
|
|
3224
|
+
error: "needs_review",
|
|
3225
|
+
message: "Coordinator device ownership needs review. Nothing changed."
|
|
3226
|
+
};
|
|
3227
|
+
process.exitCode = 1;
|
|
3228
|
+
}
|
|
3229
|
+
process.stdout.write(`${JSON.stringify({
|
|
3230
|
+
local: displayLocal(evidence),
|
|
3231
|
+
review: preview,
|
|
3232
|
+
unchanged: true,
|
|
3233
|
+
...stopped
|
|
3234
|
+
})}\n`);
|
|
3235
|
+
}
|
|
3236
|
+
async function reviewDeviceOwner(group, options) {
|
|
3237
|
+
try {
|
|
3238
|
+
if (!validId$1(group)) return failure$1(options, "Use a valid coordinator group ID.", 2);
|
|
3239
|
+
const setup = localOptions(options);
|
|
3240
|
+
const evidence = readCoordinatorOwnerReviewLocalEvidence(setup.local);
|
|
3241
|
+
if (!options.json) showLocal(evidence);
|
|
3242
|
+
if (evidence.state !== "ready" || !evidence.device || !evidence.identity) {
|
|
3243
|
+
showLocalStop(evidence, options);
|
|
3244
|
+
process.exitCode = 1;
|
|
3245
|
+
return;
|
|
3246
|
+
}
|
|
3247
|
+
if (!setup.adminSecret) return failure$1(options, "Ask the coordinator operator to configure the existing admin credential. Nothing changed.");
|
|
3248
|
+
if (!setup.remoteUrl) return failure$1(options, "Set --coordinator to a coordinator HTTPS origin (HTTP is allowed only on literal loopback).", 2);
|
|
3249
|
+
const request = {
|
|
3250
|
+
remoteUrl: setup.remoteUrl,
|
|
3251
|
+
adminSecret: setup.adminSecret,
|
|
3252
|
+
timeoutS: setup.timeoutS,
|
|
3253
|
+
groupId: group,
|
|
3254
|
+
deviceId: evidence.device.deviceId,
|
|
3255
|
+
identityId: evidence.identity.identityId,
|
|
3256
|
+
fingerprint: evidence.device.fingerprint
|
|
3257
|
+
};
|
|
3258
|
+
const preview = parsePreview(await coordinatorAuthControllerReviewAction(request), request);
|
|
3259
|
+
if (options.json) {
|
|
3260
|
+
emitReviewPreview(evidence, preview);
|
|
3261
|
+
return;
|
|
3262
|
+
}
|
|
3263
|
+
if (preview.state !== "ready") return failure$1(options, `Coordinator review stopped: ${preview.reasons.join(", ")}. Nothing changed.`);
|
|
3264
|
+
p.log.info(`Coordinator: ${preview.coordinator_id}; reviewed invitations: ${preview.reviewed_invite_count}`, { output: process.stderr });
|
|
3265
|
+
await confirmReview(group, options, evidence, request, preview);
|
|
3266
|
+
} catch (error) {
|
|
3267
|
+
failure$1(options, reviewErrorMessage(error));
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
function buildCoordinatorReviewDeviceOwnerCommand() {
|
|
3271
|
+
const command = new Command("review-device-owner").configureHelp(helpStyle).description("Preview and confirm the owner of your existing device").argument("<group>", "group id").option("-u, --coordinator <url>", "coordinator HTTPS origin (defaults to saved config)");
|
|
3272
|
+
addDbOption(command);
|
|
3273
|
+
addConfigOption(command);
|
|
3274
|
+
addJsonOption(command);
|
|
3275
|
+
command.action(reviewDeviceOwner);
|
|
3276
|
+
return command;
|
|
3277
|
+
}
|
|
3278
|
+
//#endregion
|
|
3279
|
+
//#region src/commands/coordinator-revoke-device.ts
|
|
3280
|
+
function validId(value) {
|
|
3281
|
+
return value.length > 0 && value.length <= 256 && value.trim() === value && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(value);
|
|
3282
|
+
}
|
|
3283
|
+
function setup(options) {
|
|
3284
|
+
const config = options.config ? readCodememConfigFileAtPath(options.config) : readCodememConfigFile();
|
|
3285
|
+
const remoteUrl = normalizeCoordinatorAccountLinkOrigin(config.sync_coordinator_url);
|
|
3286
|
+
const adminSecret = config.sync_coordinator_admin_secret;
|
|
3287
|
+
const timeoutS = config.sync_coordinator_timeout_s ?? 3;
|
|
3288
|
+
if (!remoteUrl || typeof adminSecret !== "string" || !adminSecret.trim() || /[\r\n\0]/u.test(adminSecret) || typeof timeoutS !== "number" || !Number.isFinite(timeoutS) || timeoutS < 1 || timeoutS > 3600) throw new Error("revocation_configuration_unavailable");
|
|
3289
|
+
return {
|
|
3290
|
+
remoteUrl,
|
|
3291
|
+
adminSecret,
|
|
3292
|
+
timeoutS
|
|
3293
|
+
};
|
|
3294
|
+
}
|
|
3295
|
+
function sameConfiguration(options, request) {
|
|
3296
|
+
try {
|
|
3297
|
+
const current = setup(options);
|
|
3298
|
+
return current.remoteUrl === request.remoteUrl && current.adminSecret === request.adminSecret && current.timeoutS === request.timeoutS;
|
|
3299
|
+
} catch {
|
|
3300
|
+
return false;
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
function failure(options, code, message, exitCode = 1) {
|
|
3304
|
+
if (options.json) {
|
|
3305
|
+
emitJsonError(code, message, exitCode);
|
|
3306
|
+
return;
|
|
3307
|
+
}
|
|
3308
|
+
p.log.error(message, { output: process.stderr });
|
|
3309
|
+
process.exitCode = exitCode;
|
|
3310
|
+
}
|
|
3311
|
+
var IMPACT = "Across the whole coordinator database: revoke this device ID with ANY key and this canonical key under ANY device ID, in ALL groups. The selected group supplies evidence only. Another device continues only when BOTH its device ID and key differ. No immediate offline enforcement, manual/direct-peer trust revocation, or erasure of remote copies. Account links, sessions, grants, memberships and stored memories are not rewritten.";
|
|
3312
|
+
function receiptLabel(value) {
|
|
3313
|
+
return JSON.stringify(value).replace(/[\p{Cc}\p{Cf}\p{Cs}\u2028\u2029]/gu, (character) => `\\u{${(character.codePointAt(0) ?? 0).toString(16)}}`);
|
|
3314
|
+
}
|
|
3315
|
+
function receiptJson(result) {
|
|
3316
|
+
return JSON.stringify(result).replace(/[\p{Cc}\p{Cf}\p{Cs}\u2028\u2029]/gu, (character) => character.split("").map((unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`).join(""));
|
|
3317
|
+
}
|
|
3318
|
+
async function confirmRevocation(options, request, preview, onDispatch) {
|
|
3319
|
+
const approved = await p.confirm({
|
|
3320
|
+
message: "Revoke BOTH subjects across the whole coordinator database?",
|
|
3321
|
+
initialValue: false,
|
|
3322
|
+
output: process.stderr
|
|
3323
|
+
});
|
|
3324
|
+
if (p.isCancel(approved) || approved !== true) {
|
|
3325
|
+
p.log.info("No revocation requested.", { output: process.stderr });
|
|
3326
|
+
return;
|
|
3327
|
+
}
|
|
3328
|
+
if (!sameConfiguration(options, request)) return failure(options, "revocation_configuration_changed", "Operator configuration changed or is unavailable. No revocation requested; preview again.");
|
|
3329
|
+
const result = await coordinatorRevokeDeviceAction({
|
|
3330
|
+
...request,
|
|
3331
|
+
expectedDigest: preview.expected_digest,
|
|
3332
|
+
expectedContext: {
|
|
3333
|
+
coordinatorId: preview.coordinator_id,
|
|
3334
|
+
keyId: preview.key_id
|
|
3335
|
+
},
|
|
3336
|
+
onDispatch
|
|
3337
|
+
});
|
|
3338
|
+
if (result.state !== "revoked") throw new Error("revocation_response_invalid");
|
|
3339
|
+
process.stdout.write(`${receiptJson(result)}\n`);
|
|
3340
|
+
p.log.success(`Coordinator ${result.coordinator_id}: both whole-database subjects revoked.`, { output: process.stderr });
|
|
3341
|
+
for (const row of result.revocations) p.log.info(`${row.subject_kind}: ${row.subject_value}; retained reference: ${receiptLabel(row.revocation_id)}; created: ${receiptLabel(row.created_at)}`, { output: process.stderr });
|
|
3342
|
+
}
|
|
3343
|
+
async function revokeDevice(groupId, deviceId, options) {
|
|
3344
|
+
let writeDispatched = false;
|
|
3345
|
+
try {
|
|
3346
|
+
if (!validId(groupId) || !validId(deviceId)) return failure(options, "revocation_invalid_input", "Use exact group and device IDs without whitespace or control characters (maximum 256 characters).", 2);
|
|
3347
|
+
const request = {
|
|
3348
|
+
...setup(options),
|
|
3349
|
+
groupId,
|
|
3350
|
+
deviceId
|
|
3351
|
+
};
|
|
3352
|
+
const preview = await coordinatorRevokeDeviceAction(request);
|
|
3353
|
+
if (preview.state !== "ready") throw new Error("revocation_response_invalid");
|
|
3354
|
+
if (options.json) {
|
|
3355
|
+
process.stdout.write(`${JSON.stringify({
|
|
3356
|
+
preview,
|
|
3357
|
+
network_request: true,
|
|
3358
|
+
unchanged: true,
|
|
3359
|
+
message: IMPACT
|
|
3360
|
+
})}\n`);
|
|
3361
|
+
return;
|
|
3362
|
+
}
|
|
3363
|
+
p.log.info(`Coordinator: ${preview.coordinator_id}\nEvidence group: ${preview.group_id}\nGlobal device ID: ${preview.device_id}\nCanonical key ID: ${preview.key_id}\nScope: ${preview.scope}`, { output: process.stderr });
|
|
3364
|
+
p.log.warn(IMPACT, { output: process.stderr });
|
|
3365
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
3366
|
+
p.log.info("Network preview only; no revocation requested. Use a terminal without --json to confirm.", { output: process.stderr });
|
|
2534
3367
|
return;
|
|
2535
3368
|
}
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
else console.error(err instanceof Error ? err.message : String(err));
|
|
2544
|
-
process.exitCode = 1;
|
|
2545
|
-
}
|
|
2546
|
-
});
|
|
2547
|
-
configCommand.addCommand(workspaceCmd);
|
|
2548
|
-
function formatWhereHuman(result) {
|
|
2549
|
-
const lines = [];
|
|
2550
|
-
const allEntries = [result.resolved, ...result.fallbackChain];
|
|
2551
|
-
const sourceOrder = {
|
|
2552
|
-
"cli-flag": 0,
|
|
2553
|
-
"env-codemem-config": 1,
|
|
2554
|
-
"env-runtime-root": 2,
|
|
2555
|
-
"env-workspace-id": 3,
|
|
2556
|
-
"legacy-global": 4
|
|
2557
|
-
};
|
|
2558
|
-
allEntries.sort((a, b) => (sourceOrder[a.source] ?? 99) - (sourceOrder[b.source] ?? 99));
|
|
2559
|
-
for (const entry of allEntries) {
|
|
2560
|
-
const marker = entry === result.resolved ? ">>>" : " ";
|
|
2561
|
-
const existsLabel = entry.exists ? "exists" : "missing";
|
|
2562
|
-
lines.push(`${marker} [${entry.source}] ${entry.path}`);
|
|
2563
|
-
lines.push(` ${entry.reason} (${existsLabel})`);
|
|
3369
|
+
await confirmRevocation(options, request, preview, () => {
|
|
3370
|
+
writeDispatched = true;
|
|
3371
|
+
});
|
|
3372
|
+
} catch (error) {
|
|
3373
|
+
if (error instanceof RemoteCoordinatorRequestError && error.status === 409 && (error.code === "revocation_preview_stale" || error.code === "revocation_target_unavailable")) return failure(options, error.code, "Revocation rejected: the target is unavailable or preview evidence changed. Preview again; do not retry automatically.");
|
|
3374
|
+
if (writeDispatched) return failure(options, "revocation_outcome_unknown", "Revocation outcome is unknown. It may have committed; do not assume rollback or retry automatically. Ask the coordinator operator to inspect both retained subjects.");
|
|
3375
|
+
failure(options, "revocation_unavailable", "Revocation preview unavailable or invalid. Check the saved HTTPS origin (HTTP only on literal loopback), operator credential and operator-enabled server context. The CLI does not activate the server. No revocation requested.");
|
|
2564
3376
|
}
|
|
2565
|
-
return lines.join("\n");
|
|
2566
3377
|
}
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
else console.log(formatWhereHuman(result));
|
|
2575
|
-
} catch (err) {
|
|
2576
|
-
if (opts.json) console.log(JSON.stringify({
|
|
2577
|
-
error: "config_error",
|
|
2578
|
-
message: err instanceof Error ? err.message : String(err)
|
|
2579
|
-
}));
|
|
2580
|
-
else console.error(err instanceof Error ? err.message : String(err));
|
|
2581
|
-
process.exitCode = 1;
|
|
2582
|
-
}
|
|
2583
|
-
});
|
|
2584
|
-
configCommand.addCommand(whereCmd);
|
|
3378
|
+
function buildCoordinatorRevokeDeviceCommand() {
|
|
3379
|
+
const command = new Command("revoke-device").configureHelp(helpStyle).description("Preview and confirm whole-database device ID and key revocation").argument("<group>", "group supplying current enrollment evidence only").argument("<device>", "device ID to revoke across all groups");
|
|
3380
|
+
addConfigOption(command);
|
|
3381
|
+
addJsonOption(command);
|
|
3382
|
+
command.action(revokeDevice);
|
|
3383
|
+
return command;
|
|
3384
|
+
}
|
|
2585
3385
|
//#endregion
|
|
2586
3386
|
//#region src/commands/coordinator.ts
|
|
2587
3387
|
/**
|
|
@@ -2616,7 +3416,7 @@ function parseOptionalInteger(value, name) {
|
|
|
2616
3416
|
}
|
|
2617
3417
|
function formatImportInviteError(error) {
|
|
2618
3418
|
const message = error instanceof Error ? error.message : String(error);
|
|
2619
|
-
const
|
|
3419
|
+
const recipientInviteReviewGuidance = "Review this invitation with `codemem coordinator import-invite <invite> --review`, then accept with --reviewed-onboarding-digest <digest> using the same profile and device name. You can also review and accept through the Viewer with `codemem serve`.";
|
|
2620
3420
|
switch (message) {
|
|
2621
3421
|
case "invite_already_bound": return "This invite was already used. Ask the sender for a new one.";
|
|
2622
3422
|
case "invite_expired": return "This invite has expired. Ask the sender for a new one.";
|
|
@@ -2631,16 +3431,66 @@ function formatImportInviteError(error) {
|
|
|
2631
3431
|
case "invite_identity_conflict":
|
|
2632
3432
|
case "recipient_invite_intent_mismatch":
|
|
2633
3433
|
case "recipient_invite_review_unavailable":
|
|
2634
|
-
case "reviewed_onboarding_digest_required":
|
|
3434
|
+
case "reviewed_onboarding_digest_required":
|
|
3435
|
+
case "reviewed_onboarding_stale": return recipientInviteReviewGuidance;
|
|
3436
|
+
case "invite_review_profile_required": return "Invite review needs an initialized device and matching keys. Run `codemem sync pair --payload-only` with the intended --db-path, --config, and CODEMEM_KEYS_DIR first, then review that profile with --keys-dir.";
|
|
3437
|
+
case "recipient_invite_review_required": return "Only Team/add-device recipient invitations need --review. Import this legacy invitation without --review.";
|
|
3438
|
+
case "invite_inspection_transport_failed": return "Could not inspect the invitation at its coordinator. Check network access and use the original coordinator URL; redirects are not accepted.";
|
|
3439
|
+
case "invite_coordinator_conflict": return "This profile already uses another coordinator. Use the intended device profile and review again.";
|
|
2635
3440
|
default: return message;
|
|
2636
3441
|
}
|
|
2637
3442
|
}
|
|
3443
|
+
function inviteImportProfile(invite, opts) {
|
|
3444
|
+
return {
|
|
3445
|
+
inviteValue: invite,
|
|
3446
|
+
dbPath: resolveDbOpt(opts) ?? null,
|
|
3447
|
+
keysDir: opts.keysDir ?? null,
|
|
3448
|
+
configPath: opts.config ?? null,
|
|
3449
|
+
recipientDisplayName: opts.recipientName?.trim() || null,
|
|
3450
|
+
deviceDisplayName: opts.deviceName?.trim() || null
|
|
3451
|
+
};
|
|
3452
|
+
}
|
|
3453
|
+
async function acceptInviteWithProfileCheck(profile, reviewedOnboardingDigest) {
|
|
3454
|
+
const digest = reviewedOnboardingDigest?.trim() || null;
|
|
3455
|
+
if (digest) {
|
|
3456
|
+
if ((await coordinatorReviewInviteAction(profile)).reviewed_onboarding_digest !== digest) throw new Error("reviewed_onboarding_stale");
|
|
3457
|
+
}
|
|
3458
|
+
return coordinatorImportInviteAction({
|
|
3459
|
+
...profile,
|
|
3460
|
+
reviewedOnboardingDigest: digest
|
|
3461
|
+
});
|
|
3462
|
+
}
|
|
3463
|
+
function rejectConflictingInviteReviewOptions(opts) {
|
|
3464
|
+
if (!opts.review || opts.reviewedOnboardingDigest === void 0) return false;
|
|
3465
|
+
const message = "Use --review or --reviewed-onboarding-digest, not both.";
|
|
3466
|
+
if (opts.json) emitJsonError("usage_error", message);
|
|
3467
|
+
else p.log.error(message);
|
|
3468
|
+
process.exitCode = 2;
|
|
3469
|
+
return true;
|
|
3470
|
+
}
|
|
3471
|
+
function printInviteReview(review, opts) {
|
|
3472
|
+
console.log(JSON.stringify(review, null, 2));
|
|
3473
|
+
if (opts.json) return;
|
|
3474
|
+
p.outro("Review only: no invitation accepted or local profile changed. To accept, rerun with --reviewed-onboarding-digest <digest> and the same profile and device name. A Team recipient display name is cosmetic and may be changed before acceptance.");
|
|
3475
|
+
}
|
|
2638
3476
|
/**
|
|
2639
3477
|
* Build a fresh coordinator command tree. Each call returns independent
|
|
2640
3478
|
* Commander instances so the tree can be mounted under multiple parents.
|
|
2641
3479
|
*/
|
|
2642
|
-
function
|
|
3480
|
+
function createCoordinatorCommandRoot() {
|
|
2643
3481
|
const cmd = new Command("coordinator").configureHelp(helpStyle).description("Manage coordinator invites, join requests, and relay server");
|
|
3482
|
+
cmd.addCommand(buildCoordinatorLinkAccountCommand());
|
|
3483
|
+
cmd.addCommand(buildCoordinatorEnrollOwnerCommand());
|
|
3484
|
+
cmd.addCommand(buildCoordinatorReviewDeviceOwnerCommand());
|
|
3485
|
+
cmd.addCommand(buildCoordinatorQualifyOwnerCommand());
|
|
3486
|
+
cmd.addCommand(buildCoordinatorGrantIdentityGroupCommand());
|
|
3487
|
+
cmd.addCommand(buildCoordinatorRevokeDeviceCommand());
|
|
3488
|
+
cmd.addCommand(buildCoordinatorAuthSchemaCommand());
|
|
3489
|
+
cmd.addCommand(buildCoordinatorAccountSchemaCommand());
|
|
3490
|
+
return cmd;
|
|
3491
|
+
}
|
|
3492
|
+
function buildCoordinatorCommand() {
|
|
3493
|
+
const cmd = createCoordinatorCommandRoot();
|
|
2644
3494
|
const groupCreateCmd = new Command("group-create").configureHelp(helpStyle).description("Create a coordinator group in the local store").argument("<group>", "group id").option("--name <name>", "display name override");
|
|
2645
3495
|
addDbOption(groupCreateCmd);
|
|
2646
3496
|
addJsonOption(groupCreateCmd);
|
|
@@ -3265,20 +4115,19 @@ function buildCoordinatorCommand() {
|
|
|
3265
4115
|
}
|
|
3266
4116
|
});
|
|
3267
4117
|
cmd.addCommand(createInviteCmd);
|
|
3268
|
-
const importInviteCmd = new Command("import-invite").configureHelp(helpStyle).description("Import a coordinator invite").argument("<invite>", "invite value or link").option("-R, --recipient-name <name>", "recipient Identity display name").option("-N, --device-name <name>", "device display name").option("--keys-dir <path>", "keys directory");
|
|
4118
|
+
const importInviteCmd = new Command("import-invite").configureHelp(helpStyle).description("Import a coordinator invite").argument("<invite>", "invite value or link").option("-R, --recipient-name <name>", "recipient Identity display name").option("-N, --device-name <name>", "device display name").option("--review", "Review recipient access without accepting").option("--reviewed-onboarding-digest <digest>", "Accept the reviewed recipient access").option("--keys-dir <path>", "keys directory");
|
|
3269
4119
|
addDbOption(importInviteCmd);
|
|
3270
4120
|
addConfigOption(importInviteCmd);
|
|
3271
4121
|
addJsonOption(importInviteCmd);
|
|
3272
4122
|
importInviteCmd.action(async (invite, opts) => {
|
|
3273
4123
|
try {
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
});
|
|
4124
|
+
if (rejectConflictingInviteReviewOptions(opts)) return;
|
|
4125
|
+
const profile = inviteImportProfile(invite, opts);
|
|
4126
|
+
if (opts.review) {
|
|
4127
|
+
printInviteReview(await coordinatorReviewInviteAction(profile), opts);
|
|
4128
|
+
return;
|
|
4129
|
+
}
|
|
4130
|
+
const result = await acceptInviteWithProfileCheck(profile, opts.reviewedOnboardingDigest);
|
|
3282
4131
|
if (opts.json) {
|
|
3283
4132
|
console.log(JSON.stringify(result, null, 2));
|
|
3284
4133
|
return;
|
|
@@ -4250,11 +5099,11 @@ async function distillAction(opts) {
|
|
|
4250
5099
|
store?.close();
|
|
4251
5100
|
}
|
|
4252
5101
|
}
|
|
4253
|
-
var cmd$
|
|
4254
|
-
addDbOption(cmd$
|
|
4255
|
-
addJsonOption(cmd$
|
|
4256
|
-
cmd$
|
|
4257
|
-
var distillCommand = cmd$
|
|
5102
|
+
var cmd$5 = new Command("distill").configureHelp(helpStyle).description("Mine recurring memories into reviewable context candidates").option("-p, --project <project>", "project identifier (defaults to git repo root)").option("-A, --all-projects", "mine memories across all projects").option("-k, --kind <kind>", "memory kind to mine (repeat or comma-separate)", collectKind, []).option("-m, --min-recurrence <n>", "minimum member count per candidate", "2").option("-l, --limit <n>", "max candidates", "10").option("-e, --explain", "include evidence snippets in human output").option("--include-documented", "include candidates already represented in context files").option("--no-judge", "skip the observer-model worthiness judgment that drops routine-activity clusters").option("-D, --draft", "draft an AGENTS.md rule for the top candidate and show the diff").option("--apply", "write the drafted rule to the target file (implies --draft; prompts to confirm; with --json applies immediately without prompting)");
|
|
5103
|
+
addDbOption(cmd$5);
|
|
5104
|
+
addJsonOption(cmd$5);
|
|
5105
|
+
cmd$5.action(distillAction);
|
|
5106
|
+
var distillCommand = cmd$5;
|
|
4258
5107
|
//#endregion
|
|
4259
5108
|
//#region src/commands/embed.ts
|
|
4260
5109
|
function parseOptionalPositiveInt(value) {
|
|
@@ -4353,12 +5202,12 @@ function invokedAsTopLevelAlias(name, argv = process.argv.slice(2)) {
|
|
|
4353
5202
|
}
|
|
4354
5203
|
//#endregion
|
|
4355
5204
|
//#region src/commands/export-memories.ts
|
|
4356
|
-
function expandUserPath(value) {
|
|
5205
|
+
function expandUserPath$1(value) {
|
|
4357
5206
|
return value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
|
|
4358
5207
|
}
|
|
4359
|
-
var cmd$
|
|
4360
|
-
addDbOption(cmd$
|
|
4361
|
-
cmd$
|
|
5208
|
+
var cmd$4 = new Command("export-memories").configureHelp(helpStyle).description("Export memories to a JSON file for sharing or backup").argument("<output>", "output file path (use '-' for stdout)").option("--project <project>", "filter by project (defaults to git repo root)").option("--all-projects", "export all projects").option("--include-inactive", "include deactivated memories").option("--since <iso>", "only export memories created after this ISO timestamp");
|
|
5209
|
+
addDbOption(cmd$4);
|
|
5210
|
+
cmd$4.action((output, opts) => {
|
|
4362
5211
|
if (output !== "-" && invokedAsTopLevelAlias("export-memories")) emitDeprecationWarning("codemem export-memories", "codemem memory export");
|
|
4363
5212
|
const payload = exportMemories({
|
|
4364
5213
|
dbPath: resolveDbPath(resolveDbOpt(opts)),
|
|
@@ -4372,7 +5221,7 @@ cmd$2.action((output, opts) => {
|
|
|
4372
5221
|
process.stdout.write(text);
|
|
4373
5222
|
return;
|
|
4374
5223
|
}
|
|
4375
|
-
const outputPath = expandUserPath(output);
|
|
5224
|
+
const outputPath = expandUserPath$1(output);
|
|
4376
5225
|
writeFileSync(outputPath, text, "utf8");
|
|
4377
5226
|
p.intro("codemem export-memories");
|
|
4378
5227
|
p.log.success([
|
|
@@ -4384,13 +5233,13 @@ cmd$2.action((output, opts) => {
|
|
|
4384
5233
|
].join("\n"));
|
|
4385
5234
|
p.outro("done");
|
|
4386
5235
|
});
|
|
4387
|
-
var exportMemoriesCommand = cmd$
|
|
5236
|
+
var exportMemoriesCommand = cmd$4;
|
|
4388
5237
|
//#endregion
|
|
4389
5238
|
//#region src/commands/import-memories.ts
|
|
4390
|
-
var cmd$
|
|
4391
|
-
addDbOption(cmd$
|
|
4392
|
-
addJsonOption(cmd$
|
|
4393
|
-
cmd$
|
|
5239
|
+
var cmd$3 = new Command("import-memories").configureHelp(helpStyle).description("Import memories from an exported JSON file").argument("<inputFile>", "input JSON file (use '-' for stdin)").option("--remap-project <path>", "remap all projects to this path on import").option("--dry-run", "preview import without writing");
|
|
5240
|
+
addDbOption(cmd$3);
|
|
5241
|
+
addJsonOption(cmd$3);
|
|
5242
|
+
cmd$3.action((inputFile, opts) => {
|
|
4394
5243
|
if (!opts.json && invokedAsTopLevelAlias("import-memories")) emitDeprecationWarning("codemem import-memories", "codemem memory import");
|
|
4395
5244
|
let payload;
|
|
4396
5245
|
try {
|
|
@@ -4451,7 +5300,7 @@ cmd$1.action((inputFile, opts) => {
|
|
|
4451
5300
|
].join("\n"));
|
|
4452
5301
|
p.outro("done");
|
|
4453
5302
|
});
|
|
4454
|
-
var importMemoriesCommand = cmd$
|
|
5303
|
+
var importMemoriesCommand = cmd$3;
|
|
4455
5304
|
//#endregion
|
|
4456
5305
|
//#region src/maintenance-worker-runtime.ts
|
|
4457
5306
|
var defaultLogger = {
|
|
@@ -4957,7 +5806,7 @@ function forgetMemoryAction(idStr, opts) {
|
|
|
4957
5806
|
}
|
|
4958
5807
|
const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
|
|
4959
5808
|
try {
|
|
4960
|
-
if (!store.
|
|
5809
|
+
if (!store.forgetForUser(memoryId)) {
|
|
4961
5810
|
if (opts.json) emitJsonError("not_found", `Memory ${memoryId} not found`);
|
|
4962
5811
|
else {
|
|
4963
5812
|
p.log.error(`Memory ${memoryId} not found`);
|
|
@@ -4965,7 +5814,6 @@ function forgetMemoryAction(idStr, opts) {
|
|
|
4965
5814
|
}
|
|
4966
5815
|
return;
|
|
4967
5816
|
}
|
|
4968
|
-
store.forget(memoryId);
|
|
4969
5817
|
if (opts.json) console.log(JSON.stringify({
|
|
4970
5818
|
id: memoryId,
|
|
4971
5819
|
status: "forgotten"
|
|
@@ -4980,39 +5828,35 @@ function parseConfidence(value) {
|
|
|
4980
5828
|
if (!Number.isFinite(n)) return .5;
|
|
4981
5829
|
return Math.min(1, Math.max(0, n));
|
|
4982
5830
|
}
|
|
4983
|
-
function
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
store.db.prepare("DELETE FROM memory_concept_refs WHERE memory_id = ?").run(memoryId);
|
|
4989
|
-
store.db.prepare("DELETE FROM replication_ops WHERE entity_type = 'memory_item' AND (entity_id = ? OR entity_id = ?)").run(row?.import_key ?? "", String(memoryId));
|
|
4990
|
-
store.db.prepare("DELETE FROM memory_items WHERE id = ?").run(memoryId);
|
|
4991
|
-
store.db.prepare(`DELETE FROM sessions
|
|
4992
|
-
WHERE id = ?
|
|
4993
|
-
AND NOT EXISTS (SELECT 1 FROM memory_items WHERE session_id = ?)`).run(sessionId, sessionId);
|
|
4994
|
-
})();
|
|
4995
|
-
}
|
|
4996
|
-
async function rememberMemoryAction(opts) {
|
|
4997
|
-
const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
|
|
4998
|
-
let sessionId = null;
|
|
4999
|
-
try {
|
|
5000
|
-
const project = resolveProject(process.cwd(), opts.project ?? null);
|
|
5001
|
-
sessionId = store.startSession({
|
|
5831
|
+
function createManualMemory(store, opts) {
|
|
5832
|
+
const project = resolveProject(process.cwd(), opts.project ?? null);
|
|
5833
|
+
const createdMemories = [];
|
|
5834
|
+
return store.db.transaction(() => {
|
|
5835
|
+
const newSessionId = store.startSession({
|
|
5002
5836
|
cwd: process.cwd(),
|
|
5003
5837
|
project,
|
|
5004
5838
|
user: process.env.USER ?? "unknown",
|
|
5005
5839
|
toolVersion: "manual",
|
|
5006
5840
|
metadata: { manual: true }
|
|
5007
5841
|
});
|
|
5008
|
-
const memId = store.
|
|
5009
|
-
if (!store.get(memId))
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5842
|
+
const memId = store.rememberForUser(newSessionId, opts.kind, opts.title, opts.body, parseConfidence(opts.confidence), opts.tags, void 0, { createdMemories });
|
|
5843
|
+
if (!store.get(memId)) throw new Error("unauthorized_scope");
|
|
5844
|
+
store.endSession(newSessionId, { manual: true });
|
|
5845
|
+
return {
|
|
5846
|
+
memId,
|
|
5847
|
+
sessionId: newSessionId,
|
|
5848
|
+
createdMemories
|
|
5849
|
+
};
|
|
5850
|
+
}).immediate();
|
|
5851
|
+
}
|
|
5852
|
+
async function rememberMemoryAction(opts) {
|
|
5853
|
+
const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
|
|
5854
|
+
let sessionId = null;
|
|
5855
|
+
try {
|
|
5856
|
+
const result = createManualMemory(store, opts);
|
|
5857
|
+
sessionId = result.sessionId;
|
|
5858
|
+
const memId = result.memId;
|
|
5859
|
+
store.enqueueCommittedVectorWrites(result.createdMemories);
|
|
5016
5860
|
await store.flushPendingVectorWrites();
|
|
5017
5861
|
if (opts.json) console.log(JSON.stringify({ id: memId }));
|
|
5018
5862
|
else p.log.success(`Stored memory ${memId}`);
|
|
@@ -7172,6 +8016,218 @@ var piHookInjectCommand = piHookInjectCmd.action(async (opts) => {
|
|
|
7172
8016
|
}
|
|
7173
8017
|
});
|
|
7174
8018
|
//#endregion
|
|
8019
|
+
//#region src/commands/pi-import-sessions.ts
|
|
8020
|
+
/**
|
|
8021
|
+
* codemem pi-import-sessions — backfill pi agent session history (design D3/D4).
|
|
8022
|
+
*
|
|
8023
|
+
* Thin wrapper over core importPiSessions: walks every session JSONL file under
|
|
8024
|
+
* <pi-agent-dir>/sessions (project subdirs included, PI_CODING_AGENT_DIR honored).
|
|
8025
|
+
*
|
|
8026
|
+
* Extraction is opt-in (--extract, off by default): it runs the observer model over
|
|
8027
|
+
* the imported history, which costs tokens proportional to backlog size. With
|
|
8028
|
+
* --extract the STANDARD flush path (flushRawEvents — the same function the
|
|
8029
|
+
* raw-event sweeper calls each tick) runs over the imported pi sessions; there is
|
|
8030
|
+
* no bespoke extraction pipeline. Without it, imported events are stored searchable
|
|
8031
|
+
* and the running viewer's sweeper picks them up like any other backlog.
|
|
8032
|
+
*/
|
|
8033
|
+
var EXTRACT_COST_NOTE = "also extract memories (runs the observer model over imported history — LLM cost scales with backlog; default: off, imported events stay searchable)";
|
|
8034
|
+
/**
|
|
8035
|
+
* Import sessions, then — when --extract is set — drain the imported pi
|
|
8036
|
+
* sessions through the standard flush path. Returns the import summary plus
|
|
8037
|
+
* extraction counts and failures.
|
|
8038
|
+
*/
|
|
8039
|
+
async function runPiImportSessions(opts, deps = {}) {
|
|
8040
|
+
const dbPath = resolveDbPath(resolveDbOpt(opts));
|
|
8041
|
+
return {
|
|
8042
|
+
summary: importPiSessions({
|
|
8043
|
+
dbPath,
|
|
8044
|
+
onProgress: deps.onProgress
|
|
8045
|
+
}),
|
|
8046
|
+
extraction: opts.extract ? await flushImportedPiSessions(dbPath, deps.observer) : {
|
|
8047
|
+
requested: false,
|
|
8048
|
+
flushedEvents: 0,
|
|
8049
|
+
failedSessions: 0,
|
|
8050
|
+
pendingSessions: null,
|
|
8051
|
+
error: null
|
|
8052
|
+
}
|
|
8053
|
+
};
|
|
8054
|
+
}
|
|
8055
|
+
/**
|
|
8056
|
+
* Run the existing raw-event flush (the sweeper's own flushRawEvents) over
|
|
8057
|
+
* every pending source-"pi" session — never a bespoke extraction path. Fails
|
|
8058
|
+
* open per session: a failed flush batch stays retryable by the sweeper's
|
|
8059
|
+
* pending-queue phase, exactly like live-session flush failures.
|
|
8060
|
+
*/
|
|
8061
|
+
async function flushImportedPiSessions(dbPath, observer) {
|
|
8062
|
+
const store = new MemoryStore(dbPath);
|
|
8063
|
+
const extraction = {
|
|
8064
|
+
requested: true,
|
|
8065
|
+
flushedEvents: 0,
|
|
8066
|
+
failedSessions: 0,
|
|
8067
|
+
pendingSessions: 0,
|
|
8068
|
+
error: null
|
|
8069
|
+
};
|
|
8070
|
+
try {
|
|
8071
|
+
let resolved;
|
|
8072
|
+
try {
|
|
8073
|
+
resolved = observer ?? new ObserverClient();
|
|
8074
|
+
} catch (err) {
|
|
8075
|
+
extraction.error = `observer init failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
8076
|
+
extraction.pendingSessions = piPendingSessions(store).length;
|
|
8077
|
+
return extraction;
|
|
8078
|
+
}
|
|
8079
|
+
for (const streamId of piPendingSessions(store)) try {
|
|
8080
|
+
const result = await flushRawEvents(store, { observer: resolved }, {
|
|
8081
|
+
opencodeSessionId: streamId,
|
|
8082
|
+
source: "pi",
|
|
8083
|
+
cwd: null,
|
|
8084
|
+
project: null,
|
|
8085
|
+
startedAt: null,
|
|
8086
|
+
maxEvents: null
|
|
8087
|
+
});
|
|
8088
|
+
extraction.flushedEvents += result.flushed;
|
|
8089
|
+
} catch (err) {
|
|
8090
|
+
extraction.failedSessions++;
|
|
8091
|
+
extraction.error ??= `session ${streamId}: ${err instanceof Error ? err.message : String(err)}`;
|
|
8092
|
+
}
|
|
8093
|
+
extraction.pendingSessions = piPendingSessions(store).length;
|
|
8094
|
+
return extraction;
|
|
8095
|
+
} finally {
|
|
8096
|
+
store.close();
|
|
8097
|
+
}
|
|
8098
|
+
}
|
|
8099
|
+
/** Pending-flush sessions attributed to source "pi", without a page cap. */
|
|
8100
|
+
function piPendingSessions(store) {
|
|
8101
|
+
return store.rawEventSessionsPendingFlush(null, "pi").map((session) => session.streamId);
|
|
8102
|
+
}
|
|
8103
|
+
/** Human-readable summary line; per-file progress is streamed by the caller. */
|
|
8104
|
+
function formatPiImportHuman(result) {
|
|
8105
|
+
const { summary, extraction } = result;
|
|
8106
|
+
const lines = [`Scanned ${summary.filesScanned} files: ${summary.filesImported} imported, ${summary.filesUnchanged} unchanged, ${summary.filesEmpty} empty, ${summary.filesErrored} errored — ${summary.inserted} events inserted, ${summary.skipped} skipped.`];
|
|
8107
|
+
if (extraction.requested) {
|
|
8108
|
+
lines.push(`Extraction: observer flushed ${extraction.flushedEvents} events into memories.`);
|
|
8109
|
+
if (extraction.failedSessions > 0 || (extraction.pendingSessions ?? 0) > 0) lines.push(`${extraction.failedSessions} sessions failed; ${extraction.pendingSessions} remain pending.`);
|
|
8110
|
+
if (extraction.error) lines.push(`Extraction error: ${extraction.error}`);
|
|
8111
|
+
}
|
|
8112
|
+
return lines.join("\n");
|
|
8113
|
+
}
|
|
8114
|
+
function logProgress(progress) {
|
|
8115
|
+
const name = progress.file;
|
|
8116
|
+
if (progress.status === "imported") p.log.info(`imported ${name} (+${progress.inserted} events)`);
|
|
8117
|
+
else if (progress.status === "unchanged") p.log.info(`unchanged ${name}`);
|
|
8118
|
+
else if (progress.status === "empty") p.log.info(`empty ${name}`);
|
|
8119
|
+
else p.log.warn(`error ${name}: ${progress.error ?? "unknown error"}`);
|
|
8120
|
+
}
|
|
8121
|
+
var cmd$2 = new Command("pi-import-sessions").configureHelp(helpStyle).description("Import pi agent session history into the searchable store").option("--extract", EXTRACT_COST_NOTE);
|
|
8122
|
+
addDbOption(cmd$2);
|
|
8123
|
+
addJsonOption(cmd$2);
|
|
8124
|
+
var piImportSessionsCommand = cmd$2.action(async (opts) => {
|
|
8125
|
+
try {
|
|
8126
|
+
const result = await runPiImportSessions(opts, opts.json ? {} : { onProgress: logProgress });
|
|
8127
|
+
const { extraction } = result;
|
|
8128
|
+
let extractionFailure;
|
|
8129
|
+
if (extraction.error || extraction.failedSessions > 0 || (extraction.pendingSessions ?? 0) > 0) {
|
|
8130
|
+
extractionFailure = extraction.error ?? `${extraction.failedSessions} sessions failed; ${extraction.pendingSessions} remain pending.`;
|
|
8131
|
+
process.exitCode = 1;
|
|
8132
|
+
}
|
|
8133
|
+
if (opts.json) {
|
|
8134
|
+
console.log(JSON.stringify({
|
|
8135
|
+
...result.summary,
|
|
8136
|
+
extraction,
|
|
8137
|
+
error: extractionFailure ? "pi_extraction_incomplete" : void 0,
|
|
8138
|
+
message: extractionFailure
|
|
8139
|
+
}, null, 2));
|
|
8140
|
+
return;
|
|
8141
|
+
}
|
|
8142
|
+
p.log.message(formatPiImportHuman(result));
|
|
8143
|
+
} catch (error) {
|
|
8144
|
+
const message = error instanceof Error ? error.message : "pi-import-sessions failed";
|
|
8145
|
+
if (opts.json) emitJsonError("pi_import_sessions_failed", message);
|
|
8146
|
+
else {
|
|
8147
|
+
p.log.error(message);
|
|
8148
|
+
process.exitCode = 1;
|
|
8149
|
+
}
|
|
8150
|
+
}
|
|
8151
|
+
});
|
|
8152
|
+
//#endregion
|
|
8153
|
+
//#region src/commands/pi-session-search.ts
|
|
8154
|
+
/**
|
|
8155
|
+
* codemem pi-session-search — search stored pi session conversations.
|
|
8156
|
+
*
|
|
8157
|
+
* Queries the raw-event store (source "pi") via core searchPiSessions — the
|
|
8158
|
+
* same contract as GET /api/pi/sessions/search and the pi-extension
|
|
8159
|
+
* memory_session_search tool (design D5). --json prints the exact response
|
|
8160
|
+
* object those surfaces return, which is what the extension's CLI fallback
|
|
8161
|
+
* parses.
|
|
8162
|
+
*
|
|
8163
|
+
* Empty results usually mean the index only covers sessions captured since
|
|
8164
|
+
* codemem was installed; `codemem pi-import-sessions` backfills history.
|
|
8165
|
+
*/
|
|
8166
|
+
var EMPTY_INDEX_HINT = "The index may only cover sessions captured since codemem was installed; backfill history with: codemem pi-import-sessions";
|
|
8167
|
+
function intOption(value) {
|
|
8168
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
8169
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
8170
|
+
}
|
|
8171
|
+
/**
|
|
8172
|
+
* Run the session search and return the shared response object.
|
|
8173
|
+
* Throws on invalid input or store failure; printing stays with the caller.
|
|
8174
|
+
*/
|
|
8175
|
+
function runPiSessionSearch(query, opts) {
|
|
8176
|
+
if (!query.trim()) throw new Error("query required");
|
|
8177
|
+
const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
|
|
8178
|
+
try {
|
|
8179
|
+
return searchPiSessions(store.db, query, {
|
|
8180
|
+
project: opts.project,
|
|
8181
|
+
session_id: opts.sessionId,
|
|
8182
|
+
limit: intOption(opts.limit),
|
|
8183
|
+
snippet_chars: intOption(opts.snippetChars)
|
|
8184
|
+
});
|
|
8185
|
+
} finally {
|
|
8186
|
+
store.close();
|
|
8187
|
+
}
|
|
8188
|
+
}
|
|
8189
|
+
/** Human-readable output: "Found N results" header + attributed snippet lines. */
|
|
8190
|
+
function formatPiSessionSearchHuman(response) {
|
|
8191
|
+
const warning = response.query_truncated ? "Warning: matching used only part of the query. Shorten it to at most 8,192 characters and 64 search terms, then retry." : null;
|
|
8192
|
+
if (response.results.length === 0) return `No results found for "${response.query}". ${warning ?? EMPTY_INDEX_HINT}`;
|
|
8193
|
+
const lines = [`Found ${response.returned} results for "${response.query}"`];
|
|
8194
|
+
if (warning) lines.push(warning);
|
|
8195
|
+
for (const [index, match] of response.results.entries()) {
|
|
8196
|
+
const attribution = [
|
|
8197
|
+
match.role,
|
|
8198
|
+
match.project ?? "no project",
|
|
8199
|
+
`session ${match.session_id}`,
|
|
8200
|
+
match.timestamp ?? "unknown time"
|
|
8201
|
+
].join(" · ");
|
|
8202
|
+
const snippet = match.snippet_truncated ? `${match.snippet}…` : match.snippet;
|
|
8203
|
+
lines.push(`${index + 1}. ${attribution}`, snippet);
|
|
8204
|
+
}
|
|
8205
|
+
if (response.truncated) lines.push(`Showing ${response.returned} of ${response.total_matches} matches.`);
|
|
8206
|
+
return lines.join("\n");
|
|
8207
|
+
}
|
|
8208
|
+
var cmd$1 = new Command("pi-session-search").configureHelp(helpStyle).description("Search stored pi session conversations by text query").argument("<query>", "search query").option("--project <project>", "filter by project label").option("--session-id <id>", "filter by pi session id").option("--limit <n>", "max results (1-20)", "10").option("--snippet-chars <n>", "per-result snippet cap in chars (100-4000)", "1200");
|
|
8209
|
+
addDbOption(cmd$1);
|
|
8210
|
+
addJsonOption(cmd$1);
|
|
8211
|
+
var piSessionSearchCommand = cmd$1.action((query, opts) => {
|
|
8212
|
+
try {
|
|
8213
|
+
const response = runPiSessionSearch(query, opts);
|
|
8214
|
+
if (opts.json) {
|
|
8215
|
+
console.log(JSON.stringify(response, null, 2));
|
|
8216
|
+
return;
|
|
8217
|
+
}
|
|
8218
|
+
const text = formatPiSessionSearchHuman(response);
|
|
8219
|
+
if (response.results.length === 0) p.log.warn(text);
|
|
8220
|
+
else p.log.message(text);
|
|
8221
|
+
} catch (error) {
|
|
8222
|
+
const message = error instanceof Error ? error.message : "pi-session-search failed";
|
|
8223
|
+
if (opts.json) emitJsonError("pi_session_search_failed", message);
|
|
8224
|
+
else {
|
|
8225
|
+
p.log.error(message);
|
|
8226
|
+
process.exitCode = 1;
|
|
8227
|
+
}
|
|
8228
|
+
}
|
|
8229
|
+
});
|
|
8230
|
+
//#endregion
|
|
7175
8231
|
//#region src/commands/recent.ts
|
|
7176
8232
|
var cmd = new Command("recent").configureHelp(helpStyle).description("Show recent memories").option("--limit <n>", "max results", "5").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "search across all projects").option("--kind <kind>", "filter by memory kind");
|
|
7177
8233
|
addDbOption(cmd);
|
|
@@ -8020,6 +9076,49 @@ function createRawEventTargetState(store) {
|
|
|
8020
9076
|
stop
|
|
8021
9077
|
};
|
|
8022
9078
|
}
|
|
9079
|
+
function createViewerAccountLink(store, invocation, serverModule) {
|
|
9080
|
+
const configPath = resolveCodememConfigPath(invocation.configPath ?? void 0).resolved.path;
|
|
9081
|
+
const keysDir = dirname(resolveKeyPaths(process.env.CODEMEM_KEYS_DIR?.trim() || void 0)[0]);
|
|
9082
|
+
const readContext = serverModule.createAccountContextReader({
|
|
9083
|
+
getProfile: () => ({
|
|
9084
|
+
dbPath: store.dbPath,
|
|
9085
|
+
deviceId: store.deviceId,
|
|
9086
|
+
identityId: store.actorId,
|
|
9087
|
+
identityLabel: store.actorDisplayName
|
|
9088
|
+
}),
|
|
9089
|
+
readConfig: () => readCodememConfigFileAtPath(configPath),
|
|
9090
|
+
configPath,
|
|
9091
|
+
keysDir
|
|
9092
|
+
});
|
|
9093
|
+
const registry = serverModule.createAccountLinkRegistry({ readContext });
|
|
9094
|
+
let actualBoundPort = null;
|
|
9095
|
+
return {
|
|
9096
|
+
registry,
|
|
9097
|
+
setBoundPort: (port) => {
|
|
9098
|
+
actualBoundPort = port;
|
|
9099
|
+
},
|
|
9100
|
+
options: {
|
|
9101
|
+
readContext,
|
|
9102
|
+
registry,
|
|
9103
|
+
getListenerAddress: () => actualBoundPort === null ? null : {
|
|
9104
|
+
host: invocation.host,
|
|
9105
|
+
port: actualBoundPort
|
|
9106
|
+
}
|
|
9107
|
+
}
|
|
9108
|
+
};
|
|
9109
|
+
}
|
|
9110
|
+
function announceViewerStartup(options) {
|
|
9111
|
+
const { pidPath, invocation, preparedDb, info } = options;
|
|
9112
|
+
writeFileSync(pidPath, JSON.stringify({
|
|
9113
|
+
pid: process.pid,
|
|
9114
|
+
host: invocation.host,
|
|
9115
|
+
port: invocation.port
|
|
9116
|
+
}), "utf-8");
|
|
9117
|
+
p.intro("codemem viewer");
|
|
9118
|
+
p.log.success(`Listening on http://${info.address}:${info.port}`);
|
|
9119
|
+
p.log.info(`Database: ${preparedDb}`);
|
|
9120
|
+
p.log.step("Raw event sweeper started");
|
|
9121
|
+
}
|
|
8023
9122
|
async function startForegroundViewer(invocation) {
|
|
8024
9123
|
const serverModule = await import("@codemem/server");
|
|
8025
9124
|
const { createApp, createSyncApp, closeStore, createViewerRawEventInbox, getStore } = serverModule;
|
|
@@ -8053,11 +9152,13 @@ async function startForegroundViewer(invocation) {
|
|
|
8053
9152
|
const config = invocation.configPath ? readCodememConfigFileAtPath(invocation.configPath) : readCodememConfigFile();
|
|
8054
9153
|
const syncConfig = readCoordinatorSyncConfig(config);
|
|
8055
9154
|
const syncEnabled = syncConfig.syncEnabled;
|
|
9155
|
+
const accountLink = createViewerAccountLink(store, invocation, serverModule);
|
|
8056
9156
|
const syncRuntimeStatus = {
|
|
8057
9157
|
phase: syncEnabled ? "starting" : "disabled",
|
|
8058
9158
|
detail: syncEnabled ? "Waiting for viewer startup to finish" : "Sync is disabled"
|
|
8059
9159
|
};
|
|
8060
9160
|
const appOpts = {
|
|
9161
|
+
accountLink: accountLink.options,
|
|
8061
9162
|
storeFactory: () => store,
|
|
8062
9163
|
...rawEventProcessing.appOptions,
|
|
8063
9164
|
getSyncRuntimeStatus: () => syncRuntimeStatus
|
|
@@ -8086,15 +9187,13 @@ async function startForegroundViewer(invocation) {
|
|
|
8086
9187
|
hostname: invocation.host,
|
|
8087
9188
|
port: invocation.port
|
|
8088
9189
|
}, (info) => {
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
p.log.info(`Database: ${preparedDb}`);
|
|
8097
|
-
p.log.step("Raw event sweeper started");
|
|
9190
|
+
accountLink.setBoundPort(info.port);
|
|
9191
|
+
announceViewerStartup({
|
|
9192
|
+
pidPath,
|
|
9193
|
+
invocation,
|
|
9194
|
+
preparedDb,
|
|
9195
|
+
info
|
|
9196
|
+
});
|
|
8098
9197
|
maintenanceWorker = startMaintenanceWorkerProcess(invocation);
|
|
8099
9198
|
if (syncEnabled) {
|
|
8100
9199
|
const syncStartDelayMs = 3e3;
|
|
@@ -8143,6 +9242,7 @@ async function startForegroundViewer(invocation) {
|
|
|
8143
9242
|
const shutdown = async () => {
|
|
8144
9243
|
p.outro("shutting down");
|
|
8145
9244
|
syncAbort.abort();
|
|
9245
|
+
await accountLink.registry.close();
|
|
8146
9246
|
await stopMaintenanceWorkerProcess(maintenanceWorker, dbPath);
|
|
8147
9247
|
await new Promise((resolve) => {
|
|
8148
9248
|
let remaining = syncServer ? 2 : 1;
|
|
@@ -9422,6 +10522,7 @@ function installPi(options = {}) {
|
|
|
9422
10522
|
p.log.info(" - Start (or restart) pi to load the extension package");
|
|
9423
10523
|
if (!options.piMcp) p.log.info(" - Optional MCP surface: install pi-mcp-adapter, then re-run with --pi-mcp");
|
|
9424
10524
|
p.log.info(" - To disable: remove the @codemem/pi-extension entry from pi settings.json packages");
|
|
10525
|
+
p.log.info(" - Pre-install pi session history is not imported automatically; backfill it with: codemem pi-import-sessions");
|
|
9425
10526
|
}
|
|
9426
10527
|
return ok;
|
|
9427
10528
|
}
|
|
@@ -10470,37 +11571,99 @@ doctorCmd.action(async (opts) => {
|
|
|
10470
11571
|
}
|
|
10471
11572
|
});
|
|
10472
11573
|
syncCommand.addCommand(doctorCmd);
|
|
10473
|
-
|
|
10474
|
-
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
|
|
10478
|
-
|
|
10479
|
-
const
|
|
10480
|
-
|
|
10481
|
-
|
|
10482
|
-
|
|
10483
|
-
|
|
10484
|
-
|
|
10485
|
-
|
|
10486
|
-
|
|
10487
|
-
|
|
10488
|
-
|
|
10489
|
-
|
|
10490
|
-
|
|
10491
|
-
|
|
10492
|
-
|
|
10493
|
-
|
|
10494
|
-
|
|
11574
|
+
function syncStatusIdentityAndPeers(store) {
|
|
11575
|
+
const d = drizzle(store.db, { schema });
|
|
11576
|
+
const deviceRow = d.select({
|
|
11577
|
+
device_id: schema.syncDevice.device_id,
|
|
11578
|
+
fingerprint: schema.syncDevice.fingerprint
|
|
11579
|
+
}).from(schema.syncDevice).limit(1).get();
|
|
11580
|
+
const peers = d.select({
|
|
11581
|
+
peer_device_id: schema.syncPeers.peer_device_id,
|
|
11582
|
+
name: schema.syncPeers.name,
|
|
11583
|
+
last_sync_at: schema.syncPeers.last_sync_at,
|
|
11584
|
+
last_error: schema.syncPeers.last_error,
|
|
11585
|
+
public_key: schema.syncPeers.public_key
|
|
11586
|
+
}).from(schema.syncPeers).all();
|
|
11587
|
+
const localDeviceId = deviceRow?.device_id ?? null;
|
|
11588
|
+
const localSigningPublicKey = localDeviceId && peers.length > 0 && store.db.prepare(`SELECT 1 FROM scope_memberships m
|
|
11589
|
+
JOIN replication_scopes s ON s.scope_id = m.scope_id
|
|
11590
|
+
WHERE m.device_id = ? AND m.status = 'active'
|
|
11591
|
+
AND s.authority_type = 'coordinator' LIMIT 1`).get(localDeviceId) ? loadRuntimeSigningPublicKey(store.db, {
|
|
11592
|
+
deviceId: localDeviceId,
|
|
11593
|
+
keysDir: process.env.CODEMEM_KEYS_DIR?.trim() || void 0
|
|
11594
|
+
}) : null;
|
|
11595
|
+
return {
|
|
11596
|
+
deviceRow,
|
|
11597
|
+
peers,
|
|
11598
|
+
peersWithScopes: peers.map((peer) => {
|
|
10495
11599
|
const peerDeviceId = String(peer.peer_device_id ?? "").trim();
|
|
10496
11600
|
return {
|
|
10497
11601
|
peer,
|
|
10498
11602
|
scopes: listPerPeerScopeSyncState(store.db, {
|
|
10499
11603
|
localDeviceId,
|
|
10500
|
-
peerDeviceId
|
|
11604
|
+
peerDeviceId,
|
|
11605
|
+
localSigningPublicKey,
|
|
11606
|
+
authenticatedPeerSigningKey: peer.public_key
|
|
10501
11607
|
})
|
|
10502
11608
|
};
|
|
11609
|
+
})
|
|
11610
|
+
};
|
|
11611
|
+
}
|
|
11612
|
+
function printSyncStatusPeers(peersWithScopes) {
|
|
11613
|
+
if (peersWithScopes.length === 0) {
|
|
11614
|
+
p.log.info("Peers: none");
|
|
11615
|
+
return;
|
|
11616
|
+
}
|
|
11617
|
+
for (const { peer, scopes } of peersWithScopes) {
|
|
11618
|
+
const peerHeader = ` ${peer.name || peer.peer_device_id}: last_sync=${peer.last_sync_at ?? "never"}, status=${peer.last_error ?? "ok"}`;
|
|
11619
|
+
if (scopes.length === 0) {
|
|
11620
|
+
p.log.message(peerHeader);
|
|
11621
|
+
continue;
|
|
11622
|
+
}
|
|
11623
|
+
const scopeLines = scopes.map((scope) => {
|
|
11624
|
+
const state = scope.bootstrapped ? "received" : "pending";
|
|
11625
|
+
return ` - ${scope.label} (${scope.scope_id}): ${state}`;
|
|
10503
11626
|
});
|
|
11627
|
+
p.log.message([
|
|
11628
|
+
peerHeader,
|
|
11629
|
+
" Spaces:",
|
|
11630
|
+
...scopeLines
|
|
11631
|
+
].join("\n"));
|
|
11632
|
+
}
|
|
11633
|
+
}
|
|
11634
|
+
function printSyncStatus(config, identityAndPeers, semanticIndex) {
|
|
11635
|
+
const { deviceRow, peers, peersWithScopes } = identityAndPeers;
|
|
11636
|
+
p.intro("codemem sync status");
|
|
11637
|
+
p.log.info([
|
|
11638
|
+
`Enabled: ${config.sync_enabled === true ? "yes" : "no"}`,
|
|
11639
|
+
`Host: ${config.sync_host ?? "0.0.0.0"}`,
|
|
11640
|
+
`Port: ${config.sync_port ?? 7337}`,
|
|
11641
|
+
`Interval: ${config.sync_interval_s ?? 120}s`,
|
|
11642
|
+
`Coordinator: ${config.sync_coordinator_url ?? "(not configured)"}`
|
|
11643
|
+
].join("\n"));
|
|
11644
|
+
if (deviceRow) p.log.info(`Device ID: ${deviceRow.device_id}\nFingerprint: ${deviceRow.fingerprint}`);
|
|
11645
|
+
else p.log.warn("Device identity not initialized (run `codemem sync enable`)");
|
|
11646
|
+
printSyncStatusPeers(peersWithScopes);
|
|
11647
|
+
p.log.info([
|
|
11648
|
+
"Semantic index:",
|
|
11649
|
+
` State: ${semanticIndex.state}`,
|
|
11650
|
+
` Summary: ${semanticIndex.summary}`,
|
|
11651
|
+
` Coverage: ${semanticIndex.indexed_memory_count}/${semanticIndex.embeddable_memory_count}`,
|
|
11652
|
+
` Mode: ${semanticIndex.mode === "semantic" ? `semantic (${semanticIndex.semantic_search_model ?? semanticIndex.current_model})` : "keyword-only"}`
|
|
11653
|
+
].join("\n"));
|
|
11654
|
+
p.outro(`${peers.length} peer(s)`);
|
|
11655
|
+
}
|
|
11656
|
+
var statusCmd = new Command("status").configureHelp(helpStyle).description("Show sync configuration and peer summary");
|
|
11657
|
+
addDbOption(statusCmd);
|
|
11658
|
+
addConfigOption(statusCmd);
|
|
11659
|
+
addJsonOption(statusCmd);
|
|
11660
|
+
statusCmd.action(function showSyncStatus(opts) {
|
|
11661
|
+
const config = readCliConfig(opts.config);
|
|
11662
|
+
const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
|
|
11663
|
+
try {
|
|
11664
|
+
const identityAndPeers = syncStatusIdentityAndPeers(store);
|
|
11665
|
+
const { deviceRow, peersWithScopes } = identityAndPeers;
|
|
11666
|
+
const semanticIndex = getSemanticIndexDiagnostics(store.db);
|
|
10504
11667
|
if (opts.json) {
|
|
10505
11668
|
console.log(JSON.stringify({
|
|
10506
11669
|
enabled: config.sync_enabled === true,
|
|
@@ -10521,40 +11684,7 @@ statusCmd.action((opts) => {
|
|
|
10521
11684
|
}, null, 2));
|
|
10522
11685
|
return;
|
|
10523
11686
|
}
|
|
10524
|
-
|
|
10525
|
-
p.log.info([
|
|
10526
|
-
`Enabled: ${config.sync_enabled === true ? "yes" : "no"}`,
|
|
10527
|
-
`Host: ${config.sync_host ?? "0.0.0.0"}`,
|
|
10528
|
-
`Port: ${config.sync_port ?? 7337}`,
|
|
10529
|
-
`Interval: ${config.sync_interval_s ?? 120}s`,
|
|
10530
|
-
`Coordinator: ${config.sync_coordinator_url ?? "(not configured)"}`
|
|
10531
|
-
].join("\n"));
|
|
10532
|
-
if (deviceRow) p.log.info(`Device ID: ${deviceRow.device_id}\nFingerprint: ${deviceRow.fingerprint}`);
|
|
10533
|
-
else p.log.warn("Device identity not initialized (run `codemem sync enable`)");
|
|
10534
|
-
if (peers.length === 0) p.log.info("Peers: none");
|
|
10535
|
-
else for (const { peer, scopes } of peersWithScopes) {
|
|
10536
|
-
const peerHeader = ` ${peer.name || peer.peer_device_id}: last_sync=${peer.last_sync_at ?? "never"}, status=${peer.last_error ?? "ok"}`;
|
|
10537
|
-
if (scopes.length === 0) p.log.message(peerHeader);
|
|
10538
|
-
else {
|
|
10539
|
-
const scopeLines = scopes.map((scope) => {
|
|
10540
|
-
const state = scope.bootstrapped ? "received" : "pending";
|
|
10541
|
-
return ` - ${scope.label} (${scope.scope_id}): ${state}`;
|
|
10542
|
-
});
|
|
10543
|
-
p.log.message([
|
|
10544
|
-
peerHeader,
|
|
10545
|
-
" Spaces:",
|
|
10546
|
-
...scopeLines
|
|
10547
|
-
].join("\n"));
|
|
10548
|
-
}
|
|
10549
|
-
}
|
|
10550
|
-
p.log.info([
|
|
10551
|
-
"Semantic index:",
|
|
10552
|
-
` State: ${semanticIndex.state}`,
|
|
10553
|
-
` Summary: ${semanticIndex.summary}`,
|
|
10554
|
-
` Coverage: ${semanticIndex.indexed_memory_count}/${semanticIndex.embeddable_memory_count}`,
|
|
10555
|
-
` Mode: ${semanticIndex.mode === "semantic" ? `semantic (${semanticIndex.semantic_search_model ?? semanticIndex.current_model})` : "keyword-only"}`
|
|
10556
|
-
].join("\n"));
|
|
10557
|
-
p.outro(`${peers.length} peer(s)`);
|
|
11687
|
+
printSyncStatus(config, identityAndPeers, semanticIndex);
|
|
10558
11688
|
} finally {
|
|
10559
11689
|
store.close();
|
|
10560
11690
|
}
|
|
@@ -10902,6 +12032,7 @@ connectCmd.action((url, opts) => {
|
|
|
10902
12032
|
syncCommand.addCommand(connectCmd);
|
|
10903
12033
|
var syncCoordinatorAlias = buildCoordinatorCommand();
|
|
10904
12034
|
syncCoordinatorAlias.hook("preAction", (_thisCmd, actionCmd) => {
|
|
12035
|
+
if (actionCmd.optsWithGlobals().json) return;
|
|
10905
12036
|
const subName = actionCmd.name();
|
|
10906
12037
|
emitDeprecationWarning(`codemem sync coordinator ${subName}`, `codemem coordinator ${subName}`);
|
|
10907
12038
|
});
|
|
@@ -11726,6 +12857,8 @@ var ROOT_COMPLETION_COMMANDS = [
|
|
|
11726
12857
|
"mcp",
|
|
11727
12858
|
"memory",
|
|
11728
12859
|
"pack",
|
|
12860
|
+
"pi-import-sessions",
|
|
12861
|
+
"pi-session-search",
|
|
11729
12862
|
"recent",
|
|
11730
12863
|
"search",
|
|
11731
12864
|
"serve",
|
|
@@ -11790,6 +12923,8 @@ function registerRootCommands(program) {
|
|
|
11790
12923
|
program.addCommand(recentCommand);
|
|
11791
12924
|
program.addCommand(searchCommand);
|
|
11792
12925
|
program.addCommand(packCommand);
|
|
12926
|
+
program.addCommand(piSessionSearchCommand);
|
|
12927
|
+
program.addCommand(piImportSessionsCommand);
|
|
11793
12928
|
program.addCommand(promptPackLedgerCommand, { hidden: true });
|
|
11794
12929
|
program.addCommand(showMemoryCommand, { hidden: true });
|
|
11795
12930
|
program.addCommand(forgetMemoryCommand, { hidden: true });
|