open-memex 0.4.0-alpha.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/README.md +191 -21
- package/README.zh-CN.md +164 -17
- package/dist/cli.js +282 -17
- package/dist/config.js +2 -0
- package/dist/distill-agents.js +62 -0
- package/dist/export.js +157 -0
- package/dist/mcp.js +14 -0
- package/dist/providers/git.js +142 -0
- package/docs/CURATOR.md +59 -0
- package/docs/TEST-PLAN.md +72 -0
- package/docs/V2-DESIGN.md +11 -2
- package/package.json +1 -1
- package/scripts/test-full.ts +345 -0
- package/src/cli.ts +295 -17
- package/src/config.ts +10 -0
- package/src/distill-agents.ts +85 -0
- package/src/export.ts +206 -0
- package/src/mcp.ts +16 -0
- package/src/providers/git.ts +191 -0
- package/src/store/sync.ts +1 -1
package/src/export.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export / import: portable memory archives (§9, D40).
|
|
3
|
+
*
|
|
4
|
+
* `export` bundles selected memories (markdown source of truth + manifest)
|
|
5
|
+
* into a single .tar.gz for moving to another machine or another app.
|
|
6
|
+
* `import` restores a bundle: personal memories go to the personal dir,
|
|
7
|
+
* project memories are re-keyed to the current project and land in the
|
|
8
|
+
* outbox as drafts (submit moves them into the repo).
|
|
9
|
+
*
|
|
10
|
+
* D40: export excludes `visibility: private` by default; `--all` / `-a`
|
|
11
|
+
* includes everything — the full-migration escape hatch.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { db } from "./store/db.ts";
|
|
18
|
+
import {
|
|
19
|
+
readMemoryFile,
|
|
20
|
+
writeMemoryFile,
|
|
21
|
+
normalizeFrontmatter,
|
|
22
|
+
} from "./store/markdown.ts";
|
|
23
|
+
import { upsertFromFile } from "./store/sync.ts";
|
|
24
|
+
import { contentHash } from "./store/lifecycle.ts";
|
|
25
|
+
|
|
26
|
+
const EXPORT_FORMAT = "open-memex-export/1";
|
|
27
|
+
|
|
28
|
+
function fail(msg: string): never {
|
|
29
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function checkTar(): void {
|
|
33
|
+
try {
|
|
34
|
+
execFileSync("tar", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
35
|
+
} catch {
|
|
36
|
+
fail("the `tar` command is required for export/import but was not found on PATH");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ExportOptions {
|
|
41
|
+
/** scope keys to include (resolved by the caller) */
|
|
42
|
+
scopeKeys: string[];
|
|
43
|
+
type?: string;
|
|
44
|
+
tag?: string;
|
|
45
|
+
/** D40: include visibility:private memories */
|
|
46
|
+
includePrivate?: boolean;
|
|
47
|
+
outFile?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ExportResult {
|
|
51
|
+
file: string;
|
|
52
|
+
exported: number;
|
|
53
|
+
skippedPrivate: number;
|
|
54
|
+
includePrivate: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ExportRow {
|
|
58
|
+
id: string;
|
|
59
|
+
scope_key: string;
|
|
60
|
+
scope: string;
|
|
61
|
+
visibility: string;
|
|
62
|
+
type: string;
|
|
63
|
+
file_path: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function exportMemories(opts: ExportOptions): ExportResult {
|
|
67
|
+
checkTar();
|
|
68
|
+
const includePrivate = opts.includePrivate ?? false;
|
|
69
|
+
|
|
70
|
+
let sql = `SELECT id, scope_key, scope, visibility, type, file_path FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")})`;
|
|
71
|
+
const params: unknown[] = [...opts.scopeKeys];
|
|
72
|
+
if (!includePrivate) sql += ` AND visibility != 'private'`;
|
|
73
|
+
if (opts.type) {
|
|
74
|
+
sql += ` AND type = ?`;
|
|
75
|
+
params.push(opts.type);
|
|
76
|
+
}
|
|
77
|
+
if (opts.tag) {
|
|
78
|
+
sql += ` AND (',' || tags || ',' LIKE ?)`;
|
|
79
|
+
params.push(`%,${opts.tag},%`);
|
|
80
|
+
}
|
|
81
|
+
sql += ` ORDER BY updated_at DESC`;
|
|
82
|
+
const rows = db().prepare(sql).all(...(params as any[])) as ExportRow[];
|
|
83
|
+
|
|
84
|
+
const skippedPrivate = includePrivate
|
|
85
|
+
? 0
|
|
86
|
+
: (db().prepare(
|
|
87
|
+
`SELECT COUNT(*) AS n FROM memories WHERE scope_key IN (${opts.scopeKeys.map(() => "?").join(",")}) AND visibility = 'private'`,
|
|
88
|
+
).get(...(opts.scopeKeys as any[])) as { n: number }).n;
|
|
89
|
+
|
|
90
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-export-"));
|
|
91
|
+
try {
|
|
92
|
+
const memDir = path.join(stage, "memories");
|
|
93
|
+
fs.mkdirSync(memDir, { recursive: true });
|
|
94
|
+
const manifestEntries: Array<{ id: string; scope: string; file: string }> = [];
|
|
95
|
+
let exported = 0;
|
|
96
|
+
for (const r of rows) {
|
|
97
|
+
const mf = readMemoryFile(r.file_path);
|
|
98
|
+
if (!mf) continue; // stale index row — skip, don't fail the export
|
|
99
|
+
const rel = path.join(r.scope, `${r.id}.md`);
|
|
100
|
+
const dest = path.join(memDir, rel);
|
|
101
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
102
|
+
fs.copyFileSync(r.file_path, dest);
|
|
103
|
+
manifestEntries.push({ id: r.id, scope: r.scope, file: path.join("memories", rel) });
|
|
104
|
+
exported++;
|
|
105
|
+
}
|
|
106
|
+
const manifest = {
|
|
107
|
+
format: EXPORT_FORMAT,
|
|
108
|
+
exported_at: new Date().toISOString(),
|
|
109
|
+
open_memex_version: readPackageVersion(),
|
|
110
|
+
include_private: includePrivate,
|
|
111
|
+
filters: {
|
|
112
|
+
scope_keys: opts.scopeKeys,
|
|
113
|
+
type: opts.type ?? null,
|
|
114
|
+
tag: opts.tag ?? null,
|
|
115
|
+
},
|
|
116
|
+
memories: manifestEntries,
|
|
117
|
+
};
|
|
118
|
+
fs.writeFileSync(path.join(stage, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
|
|
119
|
+
|
|
120
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
|
|
121
|
+
const outFile = opts.outFile ?? path.resolve(`open-memex-export-${stamp}.tar.gz`);
|
|
122
|
+
execFileSync("tar", ["-czf", outFile, "-C", stage, "manifest.json", "memories"], {
|
|
123
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
124
|
+
});
|
|
125
|
+
return { file: outFile, exported, skippedPrivate, includePrivate };
|
|
126
|
+
} finally {
|
|
127
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readPackageVersion(): string {
|
|
132
|
+
try {
|
|
133
|
+
const here = new URL(import.meta.url);
|
|
134
|
+
const pkg = path.join(path.dirname(here.pathname), "..", "package.json");
|
|
135
|
+
return (JSON.parse(fs.readFileSync(pkg, "utf8")) as { version: string }).version;
|
|
136
|
+
} catch {
|
|
137
|
+
return "unknown";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Import
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
export interface ImportOptions {
|
|
146
|
+
/** current project scope key — project memories are re-keyed to it */
|
|
147
|
+
projectScopeKey: string;
|
|
148
|
+
dryRun?: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ImportResult {
|
|
152
|
+
imported: number;
|
|
153
|
+
skippedIdentical: number;
|
|
154
|
+
skippedConflict: { id: string; file: string }[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function importBundle(bundlePath: string, opts: ImportOptions): ImportResult {
|
|
158
|
+
checkTar();
|
|
159
|
+
if (!fs.existsSync(bundlePath)) fail(`bundle not found: ${bundlePath}`);
|
|
160
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "open-memex-import-"));
|
|
161
|
+
try {
|
|
162
|
+
execFileSync("tar", ["-xzf", path.resolve(bundlePath), "-C", stage], {
|
|
163
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
164
|
+
});
|
|
165
|
+
const manifestPath = path.join(stage, "manifest.json");
|
|
166
|
+
if (!fs.existsSync(manifestPath)) fail("not an open-memex export bundle (manifest.json missing)");
|
|
167
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
|
168
|
+
format: string;
|
|
169
|
+
memories: Array<{ id: string; scope: string; file: string }>;
|
|
170
|
+
};
|
|
171
|
+
if (manifest.format !== EXPORT_FORMAT) {
|
|
172
|
+
fail(`unsupported bundle format: ${manifest.format} (expected ${EXPORT_FORMAT})`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const result: ImportResult = { imported: 0, skippedIdentical: 0, skippedConflict: [] };
|
|
176
|
+
const existingStmt = db().prepare(`SELECT content_hash FROM memories WHERE id = ?`);
|
|
177
|
+
for (const entry of manifest.memories ?? []) {
|
|
178
|
+
const src = path.join(stage, entry.file);
|
|
179
|
+
const mf = readMemoryFile(src);
|
|
180
|
+
if (!mf) continue;
|
|
181
|
+
const fm = normalizeFrontmatter({ ...mf.fm } as unknown as Record<string, unknown>);
|
|
182
|
+
// Re-key to this machine: personal stays personal; project memories
|
|
183
|
+
// adopt the current project's scope key (keys embed a path hash).
|
|
184
|
+
if (fm.scope === "project") fm.scope_key = opts.projectScopeKey;
|
|
185
|
+
else if (fm.scope === "personal") fm.scope_key = "personal";
|
|
186
|
+
|
|
187
|
+
const existing = existingStmt.get(fm.id) as { content_hash: string } | undefined;
|
|
188
|
+
if (existing) {
|
|
189
|
+
if (existing.content_hash === contentHash(mf.body)) result.skippedIdentical++;
|
|
190
|
+
else result.skippedConflict.push({ id: fm.id, file: entry.file });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (opts.dryRun) {
|
|
194
|
+
result.imported++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const { filePath } = writeMemoryFile(fm, mf.body);
|
|
198
|
+
const written = readMemoryFile(filePath);
|
|
199
|
+
if (written) upsertFromFile(written);
|
|
200
|
+
result.imported++;
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
} finally {
|
|
204
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -137,6 +137,22 @@ export async function runMcpServer() {
|
|
|
137
137
|
syncScope(PERSONAL_SCOPE.key, "session");
|
|
138
138
|
console.error(`[open-memex] MCP server up. scope=${scope.key}`);
|
|
139
139
|
|
|
140
|
+
// D12: pulls are explicit by default — session start never touches the
|
|
141
|
+
// network. With sync.autoPull, one best-effort pull; a failure never
|
|
142
|
+
// blocks the session, it just logs and continues.
|
|
143
|
+
if (cfg.sync?.autoPull) {
|
|
144
|
+
try {
|
|
145
|
+
const { GitProvider } = await import("./providers/git.ts");
|
|
146
|
+
const r = new GitProvider().pull(process.cwd());
|
|
147
|
+
syncScope(scope.key, "pull");
|
|
148
|
+
console.error(
|
|
149
|
+
`[open-memex] auto-pull: ${r.branch} ${r.fastForwarded ? "fast-forwarded" : "already up to date"}`,
|
|
150
|
+
);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error(`[open-memex] auto-pull skipped: ${(e as Error).message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
140
156
|
// D26: re-sync on every request, not just at startup. The in-repo dir
|
|
141
157
|
// follows the current git branch, so a branch switch mid-session would
|
|
142
158
|
// otherwise leave the index pointing at files that no longer exist.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitProvider — the git transport for repo-synced shared scopes (§9, D12).
|
|
3
|
+
*
|
|
4
|
+
* Git is a transport, not the product boundary: this provider moves the
|
|
5
|
+
* in-repo memory dir (`.ai/open-memex/`) between the local checkout and the
|
|
6
|
+
* remote. It never touches the appdata outbox, never force-pushes, never
|
|
7
|
+
* auto-merges a divergence — those are human decisions.
|
|
8
|
+
*
|
|
9
|
+
* Hard rules (from §9 / D12 / D36):
|
|
10
|
+
* - pull is explicit (`open-memex pull`); pull = fetch + fast-forward only.
|
|
11
|
+
* - push is explicit (`open-memex push`); the tool never pushes on its own.
|
|
12
|
+
* - a failed pull/push fails with a clear message and leaves no broken state.
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
function fail(msg: string): never {
|
|
17
|
+
throw new Error(`[open-memex] ${msg}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Run git, raising a readable error. `timeoutMs` guards network calls. */
|
|
21
|
+
function git(root: string, args: string[], timeoutMs = 0): string {
|
|
22
|
+
try {
|
|
23
|
+
return execFileSync("git", args, {
|
|
24
|
+
cwd: root,
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
27
|
+
...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
|
|
28
|
+
}).trim();
|
|
29
|
+
} catch (e) {
|
|
30
|
+
const err = e as { stderr?: string; message?: string; code?: string };
|
|
31
|
+
if (err.code === "ETIMEDOUT") fail(`git ${args.join(" ")} timed out — remote unreachable?`);
|
|
32
|
+
const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
|
|
33
|
+
fail(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Provider capability model (§2) — every provider declares what it can do.
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
export interface MemoryProviderCapabilities {
|
|
42
|
+
read: boolean;
|
|
43
|
+
write: boolean;
|
|
44
|
+
delete: boolean;
|
|
45
|
+
/** version / audit trail */
|
|
46
|
+
history: boolean;
|
|
47
|
+
sync: "none" | "pull" | "push" | "bidirectional";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MemoryProvider {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly capabilities: MemoryProviderCapabilities;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PullReceipt {
|
|
56
|
+
at: string; // RFC 3339
|
|
57
|
+
branch: string;
|
|
58
|
+
remote: string;
|
|
59
|
+
before: string; // HEAD sha before the pull
|
|
60
|
+
after: string; // HEAD sha after the pull
|
|
61
|
+
fastForwarded: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PushReceipt {
|
|
65
|
+
at: string; // RFC 3339
|
|
66
|
+
branch: string;
|
|
67
|
+
remote: string;
|
|
68
|
+
head: string; // sha pushed
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface GitSyncStatus {
|
|
72
|
+
branch: string;
|
|
73
|
+
upstream: string | null;
|
|
74
|
+
remote: string | null;
|
|
75
|
+
ahead: number;
|
|
76
|
+
behind: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
80
|
+
|
|
81
|
+
export class GitProvider implements MemoryProvider {
|
|
82
|
+
readonly name = "git";
|
|
83
|
+
readonly capabilities: MemoryProviderCapabilities = {
|
|
84
|
+
read: true,
|
|
85
|
+
write: true,
|
|
86
|
+
delete: false,
|
|
87
|
+
history: true,
|
|
88
|
+
sync: "bidirectional",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Current branch + upstream, or a clear failure when git can't answer. */
|
|
92
|
+
status(root: string): GitSyncStatus {
|
|
93
|
+
git(root, ["rev-parse", "--git-dir"]);
|
|
94
|
+
const branch =
|
|
95
|
+
git(root, ["branch", "--show-current"]) ||
|
|
96
|
+
git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
97
|
+
let upstream: string | null = null;
|
|
98
|
+
try {
|
|
99
|
+
upstream = git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
100
|
+
} catch {
|
|
101
|
+
upstream = null;
|
|
102
|
+
}
|
|
103
|
+
let ahead = 0;
|
|
104
|
+
let behind = 0;
|
|
105
|
+
if (upstream) {
|
|
106
|
+
const counts = git(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
|
|
107
|
+
const [a, b] = counts.split(/\s+/).map((n) => parseInt(n, 10));
|
|
108
|
+
ahead = Number.isFinite(a) ? a : 0;
|
|
109
|
+
behind = Number.isFinite(b) ? b : 0;
|
|
110
|
+
}
|
|
111
|
+
const remote = upstream ? upstream.split("/")[0] : null;
|
|
112
|
+
return { branch, upstream, remote, ahead, behind };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Explicit pull: fetch + fast-forward only (§9).
|
|
117
|
+
* Diverged branches are NOT merged — the user resolves them by hand.
|
|
118
|
+
*/
|
|
119
|
+
pull(root: string): PullReceipt {
|
|
120
|
+
const st = this.status(root);
|
|
121
|
+
if (!st.upstream || !st.remote) {
|
|
122
|
+
fail(
|
|
123
|
+
`branch ${st.branch} has no upstream — set one with ` +
|
|
124
|
+
`\`git push -u <remote> ${st.branch}\`, then pull again`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
git(root, ["fetch", st.remote], FETCH_TIMEOUT_MS);
|
|
128
|
+
const before = git(root, ["rev-parse", "HEAD"]);
|
|
129
|
+
const remoteSha = git(root, ["rev-parse", st.upstream]);
|
|
130
|
+
if (before === remoteSha) {
|
|
131
|
+
return {
|
|
132
|
+
at: new Date().toISOString(),
|
|
133
|
+
branch: st.branch,
|
|
134
|
+
remote: st.remote,
|
|
135
|
+
before,
|
|
136
|
+
after: before,
|
|
137
|
+
fastForwarded: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// Fast-forward is possible iff HEAD is an ancestor of the upstream.
|
|
141
|
+
let ffPossible = false;
|
|
142
|
+
try {
|
|
143
|
+
git(root, ["merge-base", "--is-ancestor", "HEAD", st.upstream]);
|
|
144
|
+
ffPossible = true;
|
|
145
|
+
} catch {
|
|
146
|
+
ffPossible = false;
|
|
147
|
+
}
|
|
148
|
+
if (!ffPossible) {
|
|
149
|
+
fail(
|
|
150
|
+
`branch ${st.branch} has diverged from ${st.upstream} — ` +
|
|
151
|
+
`open-memex never force-merges; resolve it by hand ` +
|
|
152
|
+
`(rebase or merge), then pull again`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
git(root, ["merge", "--ff-only", st.upstream]);
|
|
156
|
+
const after = git(root, ["rev-parse", "HEAD"]);
|
|
157
|
+
return {
|
|
158
|
+
at: new Date().toISOString(),
|
|
159
|
+
branch: st.branch,
|
|
160
|
+
remote: st.remote,
|
|
161
|
+
before,
|
|
162
|
+
after,
|
|
163
|
+
fastForwarded: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Explicit push of the current branch. Never called automatically. */
|
|
168
|
+
push(root: string): PushReceipt {
|
|
169
|
+
const st = this.status(root);
|
|
170
|
+
if (!st.remote) {
|
|
171
|
+
// No upstream yet: push explicitly sets it (-u), still user-invoked.
|
|
172
|
+
const remotes = git(root, ["remote"]);
|
|
173
|
+
const remote = remotes.split("\n").map((r) => r.trim()).filter(Boolean)[0];
|
|
174
|
+
if (!remote) fail("no git remote configured — add one before pushing");
|
|
175
|
+
git(root, ["push", "-u", remote, st.branch], FETCH_TIMEOUT_MS);
|
|
176
|
+
return {
|
|
177
|
+
at: new Date().toISOString(),
|
|
178
|
+
branch: st.branch,
|
|
179
|
+
remote,
|
|
180
|
+
head: git(root, ["rev-parse", "HEAD"]),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
git(root, ["push", st.remote, st.branch], FETCH_TIMEOUT_MS);
|
|
184
|
+
return {
|
|
185
|
+
at: new Date().toISOString(),
|
|
186
|
+
branch: st.branch,
|
|
187
|
+
remote: st.remote,
|
|
188
|
+
head: git(root, ["rev-parse", "HEAD"]),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/store/sync.ts
CHANGED
|
@@ -153,7 +153,7 @@ export function syncScope(scopeKey: string, kind: SyncKind = "auto"): SyncStats
|
|
|
153
153
|
// ---------------------------------------------------------------------------
|
|
154
154
|
|
|
155
155
|
/** What triggered the sync — shown in sync-status so the user can see it. */
|
|
156
|
-
export type SyncKind = "session" | "request" | "cli" | "submit" | "auto";
|
|
156
|
+
export type SyncKind = "session" | "request" | "cli" | "submit" | "pull" | "push" | "auto";
|
|
157
157
|
|
|
158
158
|
export interface SyncStateEntry {
|
|
159
159
|
lastSyncAt: string; // RFC 3339
|