@notis_ai/cli 0.2.0-beta.165.1 → 0.2.0-beta.167.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-hooks/notis-agent-hook.mjs +74 -18
- package/dist/skill-sync/index.js +74 -18
- package/dist/skill-sync/index.js.map +2 -2
- package/dist/skill-sync-worker.mjs +69 -16
- package/package.json +1 -1
- package/src/runtime/skill-sync/index.ts +42 -8
- package/src/runtime/skill-sync/local-scanner.ts +66 -9
- package/src/runtime/skill-sync/types.ts +11 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +4 -4
|
@@ -1881,10 +1881,34 @@ async function extractZipToDirectory(zipPath, destinationDir) {
|
|
|
1881
1881
|
await fs.rm(destinationDir, { recursive: true, force: true });
|
|
1882
1882
|
await fs.mkdir(path.dirname(destinationDir), { recursive: true });
|
|
1883
1883
|
await fs.cp(sourceDir, destinationDir, { recursive: true });
|
|
1884
|
+
return await listRelativeFiles(destinationDir);
|
|
1884
1885
|
} finally {
|
|
1885
1886
|
await fs.rm(extractRoot, { recursive: true, force: true });
|
|
1886
1887
|
}
|
|
1887
1888
|
}
|
|
1889
|
+
async function listRelativeFiles(dirPath) {
|
|
1890
|
+
try {
|
|
1891
|
+
const files = await listFilesRecursive(dirPath);
|
|
1892
|
+
return files.map((filePath) => toPosixRelativePath(dirPath, filePath)).sort();
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
if (error?.code === "ENOENT") return [];
|
|
1895
|
+
throw error;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
function toPosixRelativePath(rootDir, filePath) {
|
|
1899
|
+
return path.relative(rootDir, filePath).split(path.sep).join("/");
|
|
1900
|
+
}
|
|
1901
|
+
async function preserveUnmanagedFiles(existingDir, stagingDir, previouslyApplied) {
|
|
1902
|
+
const managed = previouslyApplied ? new Set(previouslyApplied) : null;
|
|
1903
|
+
for (const existingFile of await listFilesRecursive(existingDir)) {
|
|
1904
|
+
const relativePath = toPosixRelativePath(existingDir, existingFile);
|
|
1905
|
+
if (managed?.has(relativePath)) continue;
|
|
1906
|
+
const stagedPath = path.join(stagingDir, ...relativePath.split("/"));
|
|
1907
|
+
if (await pathExists(stagedPath)) continue;
|
|
1908
|
+
await fs.mkdir(path.dirname(stagedPath), { recursive: true });
|
|
1909
|
+
await fs.copyFile(existingFile, stagedPath);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1888
1912
|
async function pathExists(targetPath) {
|
|
1889
1913
|
try {
|
|
1890
1914
|
await fs.access(targetPath);
|
|
@@ -1893,7 +1917,7 @@ async function pathExists(targetPath) {
|
|
|
1893
1917
|
return false;
|
|
1894
1918
|
}
|
|
1895
1919
|
}
|
|
1896
|
-
async function replaceSkillDirectoryAtomically(skillDir, populateDir) {
|
|
1920
|
+
async function replaceSkillDirectoryAtomically(skillDir, populateDir, preserve) {
|
|
1897
1921
|
const parentDir = path.dirname(skillDir);
|
|
1898
1922
|
const skillName = path.basename(skillDir);
|
|
1899
1923
|
await fs.mkdir(parentDir, { recursive: true });
|
|
@@ -1909,6 +1933,9 @@ async function replaceSkillDirectoryAtomically(skillDir, populateDir) {
|
|
|
1909
1933
|
let cleanupError = null;
|
|
1910
1934
|
try {
|
|
1911
1935
|
await populateDir(stagingDir);
|
|
1936
|
+
if (preserve && await pathExists(skillDir)) {
|
|
1937
|
+
await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied);
|
|
1938
|
+
}
|
|
1912
1939
|
if (await pathExists(skillDir)) {
|
|
1913
1940
|
await fs.rename(skillDir, backupDir);
|
|
1914
1941
|
movedExisting = true;
|
|
@@ -1956,11 +1983,12 @@ async function createSkillBundleBase64(skill) {
|
|
|
1956
1983
|
await fs.rm(zipPath, { force: true });
|
|
1957
1984
|
}
|
|
1958
1985
|
}
|
|
1959
|
-
async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PATHS) {
|
|
1986
|
+
async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PATHS, options = {}) {
|
|
1960
1987
|
const skillDir = path.join(
|
|
1961
1988
|
paths.skillsDir,
|
|
1962
1989
|
safeName(skill.name, paths.skillsDir)
|
|
1963
1990
|
);
|
|
1991
|
+
const preserve = { previouslyApplied: options.previouslyApplied };
|
|
1964
1992
|
if (bundleBytes?.length) {
|
|
1965
1993
|
const bundlePath = path.join(
|
|
1966
1994
|
os.tmpdir(),
|
|
@@ -1968,8 +1996,11 @@ async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PA
|
|
|
1968
1996
|
);
|
|
1969
1997
|
try {
|
|
1970
1998
|
await fs.writeFile(bundlePath, bundleBytes);
|
|
1971
|
-
|
|
1972
|
-
|
|
1999
|
+
let appliedFiles = [];
|
|
2000
|
+
await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
|
|
2001
|
+
appliedFiles = await extractZipToDirectory(bundlePath, stagingDir);
|
|
2002
|
+
}, preserve);
|
|
2003
|
+
return { written: true, appliedFiles };
|
|
1973
2004
|
} finally {
|
|
1974
2005
|
await fs.rm(bundlePath, { force: true });
|
|
1975
2006
|
}
|
|
@@ -1983,6 +2014,7 @@ async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PA
|
|
|
1983
2014
|
`Synced bundle for "${skill.name}" is missing SKILL.md or SKILLS.md`
|
|
1984
2015
|
);
|
|
1985
2016
|
}
|
|
2017
|
+
const appliedFiles = [];
|
|
1986
2018
|
await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
|
|
1987
2019
|
for (const bundleFile of skill.bundle_files || []) {
|
|
1988
2020
|
const filePath = resolveSkillBundleFilePath(
|
|
@@ -1994,9 +2026,10 @@ async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PA
|
|
|
1994
2026
|
filePath,
|
|
1995
2027
|
Buffer.from(bundleFile.content_b64, "base64")
|
|
1996
2028
|
);
|
|
2029
|
+
appliedFiles.push(toPosixRelativePath(stagingDir, filePath));
|
|
1997
2030
|
}
|
|
1998
|
-
});
|
|
1999
|
-
return true;
|
|
2031
|
+
}, preserve);
|
|
2032
|
+
return { written: true, appliedFiles: appliedFiles.sort() };
|
|
2000
2033
|
}
|
|
2001
2034
|
await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
|
|
2002
2035
|
await fs.writeFile(
|
|
@@ -2004,8 +2037,8 @@ async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PA
|
|
|
2004
2037
|
skill.skill_md ?? "",
|
|
2005
2038
|
"utf8"
|
|
2006
2039
|
);
|
|
2007
|
-
});
|
|
2008
|
-
return true;
|
|
2040
|
+
}, preserve);
|
|
2041
|
+
return { written: true, appliedFiles: ["SKILL.md"] };
|
|
2009
2042
|
}
|
|
2010
2043
|
async function requestJson(url, jwt, options = {}) {
|
|
2011
2044
|
const response = await fetch(url, {
|
|
@@ -2399,7 +2432,7 @@ async function writeCloudSkillWithBundleFallback(skill, dependencies) {
|
|
|
2399
2432
|
const bundleBytes = await dependencies.downloadSkillBundle(skill.skill_source_url);
|
|
2400
2433
|
const wroteBundleToDisk = await dependencies.writeCloudSkillToDisk(skill, bundleBytes);
|
|
2401
2434
|
if (wroteBundleToDisk) {
|
|
2402
|
-
return
|
|
2435
|
+
return wroteBundleToDisk;
|
|
2403
2436
|
}
|
|
2404
2437
|
dependencies.onWarning?.(
|
|
2405
2438
|
`Bundle sync for "${skill.name}" produced no local changes, falling back to SKILL.md payload.`,
|
|
@@ -2511,12 +2544,20 @@ function collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillN
|
|
|
2511
2544
|
}
|
|
2512
2545
|
return applied;
|
|
2513
2546
|
}
|
|
2514
|
-
function
|
|
2547
|
+
function collectAppliedFiles(pullResponse, previousState, appliedFilesByName) {
|
|
2548
|
+
const applied = {};
|
|
2549
|
+
for (const skill of selectCloudSkillsToApply(pullResponse.skills)) {
|
|
2550
|
+
applied[skill.name] = appliedFilesByName[skill.name] ?? previousState.skills[skill.name]?.appliedFiles;
|
|
2551
|
+
}
|
|
2552
|
+
return applied;
|
|
2553
|
+
}
|
|
2554
|
+
function buildSyncState(pullResponse, localSkills, lastSyncedAt, verifiedAgentLinks = {}, failedContentNames = /* @__PURE__ */ new Set(), appliedRevisions = {}, appliedFiles = {}) {
|
|
2515
2555
|
const localSkillMap = toSkillMap(localSkills);
|
|
2516
2556
|
const skills = Object.fromEntries(
|
|
2517
2557
|
selectCloudSkillsToApply(pullResponse.skills).map((skill) => {
|
|
2518
2558
|
const localSkill = localSkillMap.get(skill.name);
|
|
2519
2559
|
const appliedCloudFolderHash = appliedRevisions[skill.name];
|
|
2560
|
+
const appliedFileList = appliedFiles[skill.name];
|
|
2520
2561
|
return [
|
|
2521
2562
|
skill.name,
|
|
2522
2563
|
{
|
|
@@ -2527,6 +2568,7 @@ function buildSyncState(pullResponse, localSkills, lastSyncedAt, verifiedAgentLi
|
|
|
2527
2568
|
cloudUpdatedAt: skill.updated_at,
|
|
2528
2569
|
...!failedContentNames.has(skill.name) && !skill.skill_source_url ? { cloudContentHash: cloudContentHash(skill) } : {},
|
|
2529
2570
|
...appliedCloudFolderHash !== void 0 ? { appliedCloudFolderHash } : {},
|
|
2571
|
+
...appliedFileList !== void 0 ? { appliedFiles: appliedFileList } : {},
|
|
2530
2572
|
syncedAt: lastSyncedAt || (/* @__PURE__ */ new Date()).toISOString()
|
|
2531
2573
|
}
|
|
2532
2574
|
];
|
|
@@ -2582,7 +2624,7 @@ function applyLegacyFirstRunState(localSkills, scopedState, legacyState) {
|
|
|
2582
2624
|
skills: migratedSkills
|
|
2583
2625
|
};
|
|
2584
2626
|
}
|
|
2585
|
-
async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previousState, syncPaths, deps, failures = [], writtenSkillNames = /* @__PURE__ */ new Set()) {
|
|
2627
|
+
async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previousState, syncPaths, deps, failures = [], writtenSkillNames = /* @__PURE__ */ new Set(), appliedFilesByName = {}) {
|
|
2586
2628
|
const localSkillMap = toSkillMap(localSkills);
|
|
2587
2629
|
const warnSkillSync = (message, error) => {
|
|
2588
2630
|
console.warn(`[Notis] ${message}`, error);
|
|
@@ -2592,13 +2634,21 @@ async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previo
|
|
|
2592
2634
|
if (!shouldWriteCloudSkill(cloudSkill, localSkillMap, previousState)) {
|
|
2593
2635
|
continue;
|
|
2594
2636
|
}
|
|
2595
|
-
|
|
2637
|
+
const outcome = await writeCloudSkillWithBundleFallback(cloudSkill, {
|
|
2596
2638
|
downloadSkillBundle: deps.downloadSkillBundle,
|
|
2597
|
-
writeCloudSkillToDisk: (skill, bundleBytes) => deps.writeCloudSkillToDisk(skill, bundleBytes, syncPaths
|
|
2639
|
+
writeCloudSkillToDisk: (skill, bundleBytes) => deps.writeCloudSkillToDisk(skill, bundleBytes, syncPaths, {
|
|
2640
|
+
// What the last write put there. Everything else in the folder was
|
|
2641
|
+
// produced locally and survives this one.
|
|
2642
|
+
previouslyApplied: previousState.skills[skill.name]?.appliedFiles
|
|
2643
|
+
}),
|
|
2598
2644
|
onWarning: warnSkillSync
|
|
2599
|
-
})
|
|
2645
|
+
});
|
|
2646
|
+
if (outcome) {
|
|
2600
2647
|
downloaded += 1;
|
|
2601
2648
|
writtenSkillNames.add(cloudSkill.name);
|
|
2649
|
+
if (typeof outcome === "object" && Array.isArray(outcome.appliedFiles)) {
|
|
2650
|
+
appliedFilesByName[cloudSkill.name] = outcome.appliedFiles;
|
|
2651
|
+
}
|
|
2602
2652
|
} else {
|
|
2603
2653
|
failures.push({ name: cloudSkill.name, error: "Skill content could not be downloaded or written; sync will retry" });
|
|
2604
2654
|
}
|
|
@@ -2769,6 +2819,7 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
|
|
|
2769
2819
|
}
|
|
2770
2820
|
const failedDownloads = [];
|
|
2771
2821
|
const writtenSkillNames = /* @__PURE__ */ new Set();
|
|
2822
|
+
const appliedFilesByName = {};
|
|
2772
2823
|
const downloaded = await writePulledSkillsToScopedMirror(
|
|
2773
2824
|
pullResponse,
|
|
2774
2825
|
localSkills,
|
|
@@ -2776,7 +2827,8 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
|
|
|
2776
2827
|
syncPaths,
|
|
2777
2828
|
deps,
|
|
2778
2829
|
failedDownloads,
|
|
2779
|
-
writtenSkillNames
|
|
2830
|
+
writtenSkillNames,
|
|
2831
|
+
appliedFilesByName
|
|
2780
2832
|
);
|
|
2781
2833
|
const finalLocalSkills = (await deps.scanLocalSkills(syncPaths)).filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
|
|
2782
2834
|
const symlinkResult = await deps.syncSymlinks(
|
|
@@ -2793,7 +2845,8 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
|
|
|
2793
2845
|
lastSyncedAt,
|
|
2794
2846
|
verifiedLinks,
|
|
2795
2847
|
new Set(failedDownloads.map((item) => item.name)),
|
|
2796
|
-
collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames)
|
|
2848
|
+
collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames),
|
|
2849
|
+
collectAppliedFiles(pullResponse, previousState, appliedFilesByName)
|
|
2797
2850
|
),
|
|
2798
2851
|
syncPaths
|
|
2799
2852
|
);
|
package/package.json
CHANGED
|
@@ -231,6 +231,24 @@ function collectAppliedCloudRevisions(
|
|
|
231
231
|
return applied;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The files each folder's last successful write put on disk: this run's list for the
|
|
236
|
+
* skills we just wrote, the previously recorded one otherwise. Missing means "no
|
|
237
|
+
* record", which keeps local files rather than deleting what cannot be attributed.
|
|
238
|
+
*/
|
|
239
|
+
function collectAppliedFiles(
|
|
240
|
+
pullResponse: SyncPullResponse,
|
|
241
|
+
previousState: NotisSyncState,
|
|
242
|
+
appliedFilesByName: Record<string, string[]>,
|
|
243
|
+
): Record<string, string[] | undefined> {
|
|
244
|
+
const applied: Record<string, string[] | undefined> = {};
|
|
245
|
+
for (const skill of selectCloudSkillsToApply(pullResponse.skills)) {
|
|
246
|
+
applied[skill.name] = appliedFilesByName[skill.name]
|
|
247
|
+
?? previousState.skills[skill.name]?.appliedFiles;
|
|
248
|
+
}
|
|
249
|
+
return applied;
|
|
250
|
+
}
|
|
251
|
+
|
|
234
252
|
function buildSyncState(
|
|
235
253
|
pullResponse: SyncPullResponse,
|
|
236
254
|
localSkills: LocalSkill[],
|
|
@@ -238,6 +256,7 @@ function buildSyncState(
|
|
|
238
256
|
verifiedAgentLinks: Record<string, Partial<AgentTargets>> = {},
|
|
239
257
|
failedContentNames: ReadonlySet<string> = new Set(),
|
|
240
258
|
appliedRevisions: AppliedCloudRevisions = {},
|
|
259
|
+
appliedFiles: Record<string, string[] | undefined> = {},
|
|
241
260
|
): NotisSyncState {
|
|
242
261
|
const localSkillMap = toSkillMap(localSkills);
|
|
243
262
|
// The same row selection the writer used, so the state describes the revision that
|
|
@@ -246,6 +265,7 @@ function buildSyncState(
|
|
|
246
265
|
selectCloudSkillsToApply(pullResponse.skills).map((skill) => {
|
|
247
266
|
const localSkill = localSkillMap.get(skill.name);
|
|
248
267
|
const appliedCloudFolderHash = appliedRevisions[skill.name];
|
|
268
|
+
const appliedFileList = appliedFiles[skill.name];
|
|
249
269
|
return [
|
|
250
270
|
skill.name,
|
|
251
271
|
{
|
|
@@ -257,6 +277,7 @@ function buildSyncState(
|
|
|
257
277
|
...(!failedContentNames.has(skill.name) && !skill.skill_source_url
|
|
258
278
|
? { cloudContentHash: cloudContentHash(skill) } : {}),
|
|
259
279
|
...(appliedCloudFolderHash !== undefined ? { appliedCloudFolderHash } : {}),
|
|
280
|
+
...(appliedFileList !== undefined ? { appliedFiles: appliedFileList } : {}),
|
|
260
281
|
syncedAt: lastSyncedAt || new Date().toISOString(),
|
|
261
282
|
},
|
|
262
283
|
];
|
|
@@ -342,6 +363,7 @@ async function writePulledSkillsToScopedMirror(
|
|
|
342
363
|
>,
|
|
343
364
|
failures: SkillSyncFailure[] = [],
|
|
344
365
|
writtenSkillNames: Set<string> = new Set(),
|
|
366
|
+
appliedFilesByName: Record<string, string[]> = {},
|
|
345
367
|
): Promise<number> {
|
|
346
368
|
const localSkillMap = toSkillMap(localSkills);
|
|
347
369
|
const warnSkillSync = (message: string, error: unknown): void => {
|
|
@@ -354,16 +376,22 @@ async function writePulledSkillsToScopedMirror(
|
|
|
354
376
|
continue;
|
|
355
377
|
}
|
|
356
378
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
writeCloudSkillToDisk
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
379
|
+
const outcome = await writeCloudSkillWithBundleFallback(cloudSkill, {
|
|
380
|
+
downloadSkillBundle: deps.downloadSkillBundle,
|
|
381
|
+
writeCloudSkillToDisk: (skill, bundleBytes) =>
|
|
382
|
+
deps.writeCloudSkillToDisk(skill, bundleBytes, syncPaths, {
|
|
383
|
+
// What the last write put there. Everything else in the folder was
|
|
384
|
+
// produced locally and survives this one.
|
|
385
|
+
previouslyApplied: previousState.skills[skill.name]?.appliedFiles,
|
|
386
|
+
}),
|
|
387
|
+
onWarning: warnSkillSync,
|
|
388
|
+
});
|
|
389
|
+
if (outcome) {
|
|
365
390
|
downloaded += 1;
|
|
366
391
|
writtenSkillNames.add(cloudSkill.name);
|
|
392
|
+
if (typeof outcome === "object" && Array.isArray(outcome.appliedFiles)) {
|
|
393
|
+
appliedFilesByName[cloudSkill.name] = outcome.appliedFiles;
|
|
394
|
+
}
|
|
367
395
|
} else {
|
|
368
396
|
failures.push({ name: cloudSkill.name, error: "Skill content could not be downloaded or written; sync will retry" });
|
|
369
397
|
}
|
|
@@ -425,6 +453,7 @@ export async function materializeCloudSkillsForLocalShell(
|
|
|
425
453
|
const localSkills = await deps.scanLocalSkills(syncPaths);
|
|
426
454
|
const failedDownloads: SkillSyncFailure[] = [];
|
|
427
455
|
const writtenSkillNames = new Set<string>();
|
|
456
|
+
const appliedFilesByName: Record<string, string[]> = {};
|
|
428
457
|
const downloaded = await writePulledSkillsToScopedMirror(
|
|
429
458
|
pullResponse,
|
|
430
459
|
localSkills,
|
|
@@ -433,6 +462,7 @@ export async function materializeCloudSkillsForLocalShell(
|
|
|
433
462
|
deps,
|
|
434
463
|
failedDownloads,
|
|
435
464
|
writtenSkillNames,
|
|
465
|
+
appliedFilesByName,
|
|
436
466
|
);
|
|
437
467
|
const finalLocalSkills = await deps.scanLocalSkills(syncPaths);
|
|
438
468
|
const lastSyncedAt = pullResponse.last_synced_at || new Date().toISOString();
|
|
@@ -465,6 +495,7 @@ export async function materializeCloudSkillsForLocalShell(
|
|
|
465
495
|
pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks,
|
|
466
496
|
new Set(failedDownloads.map(item => item.name)),
|
|
467
497
|
collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames),
|
|
498
|
+
collectAppliedFiles(pullResponse, previousState, appliedFilesByName),
|
|
468
499
|
);
|
|
469
500
|
// Pull-only refresh is not an upload acknowledgement. Keep content baselines
|
|
470
501
|
// unless we actually wrote cloud content, and retain cloud-missing entries so
|
|
@@ -701,6 +732,7 @@ export async function runSkillSync(
|
|
|
701
732
|
|
|
702
733
|
const failedDownloads: SkillSyncFailure[] = [];
|
|
703
734
|
const writtenSkillNames = new Set<string>();
|
|
735
|
+
const appliedFilesByName: Record<string, string[]> = {};
|
|
704
736
|
const downloaded = await writePulledSkillsToScopedMirror(
|
|
705
737
|
pullResponse,
|
|
706
738
|
localSkills,
|
|
@@ -709,6 +741,7 @@ export async function runSkillSync(
|
|
|
709
741
|
deps,
|
|
710
742
|
failedDownloads,
|
|
711
743
|
writtenSkillNames,
|
|
744
|
+
appliedFilesByName,
|
|
712
745
|
);
|
|
713
746
|
|
|
714
747
|
const finalLocalSkills = (await deps.scanLocalSkills(syncPaths))
|
|
@@ -729,6 +762,7 @@ export async function runSkillSync(
|
|
|
729
762
|
verifiedLinks,
|
|
730
763
|
new Set(failedDownloads.map(item => item.name)),
|
|
731
764
|
collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames),
|
|
765
|
+
collectAppliedFiles(pullResponse, previousState, appliedFilesByName),
|
|
732
766
|
),
|
|
733
767
|
syncPaths,
|
|
734
768
|
);
|
|
@@ -6,7 +6,7 @@ import os from "os";
|
|
|
6
6
|
import path from "path";
|
|
7
7
|
import { promisify } from "util";
|
|
8
8
|
|
|
9
|
-
import type { CloudSkill, LocalSkill, NotisSyncState } from "./types";
|
|
9
|
+
import type { CloudSkill, LocalSkill, NotisSyncState, SkillWriteOutcome } from "./types";
|
|
10
10
|
|
|
11
11
|
const HOME_DIR = os.homedir();
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
@@ -860,7 +860,7 @@ async function createZipFromDirectory(directoryPath: string): Promise<string> {
|
|
|
860
860
|
async function extractZipToDirectory(
|
|
861
861
|
zipPath: string,
|
|
862
862
|
destinationDir: string,
|
|
863
|
-
): Promise<
|
|
863
|
+
): Promise<string[]> {
|
|
864
864
|
const extractRoot = await fs.mkdtemp(
|
|
865
865
|
path.join(os.tmpdir(), "notis-skill-extract-"),
|
|
866
866
|
);
|
|
@@ -889,11 +889,56 @@ async function extractZipToDirectory(
|
|
|
889
889
|
await fs.rm(destinationDir, { recursive: true, force: true });
|
|
890
890
|
await fs.mkdir(path.dirname(destinationDir), { recursive: true });
|
|
891
891
|
await fs.cp(sourceDir, destinationDir, { recursive: true });
|
|
892
|
+
return await listRelativeFiles(destinationDir);
|
|
892
893
|
} finally {
|
|
893
894
|
await fs.rm(extractRoot, { recursive: true, force: true });
|
|
894
895
|
}
|
|
895
896
|
}
|
|
896
897
|
|
|
898
|
+
async function listRelativeFiles(dirPath: string): Promise<string[]> {
|
|
899
|
+
try {
|
|
900
|
+
const files = await listFilesRecursive(dirPath);
|
|
901
|
+
return files.map((filePath) => toPosixRelativePath(dirPath, filePath)).sort();
|
|
902
|
+
} catch (error) {
|
|
903
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return [];
|
|
904
|
+
throw error;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function toPosixRelativePath(rootDir: string, filePath: string): string {
|
|
909
|
+
return path.relative(rootDir, filePath).split(path.sep).join("/");
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Carry local-only files across a cloud write. A skill that writes while it runs
|
|
914
|
+
* (a run ledger under its own folder) would otherwise lose that work every time the
|
|
915
|
+
* cloud content changed, because the write replaces the whole directory.
|
|
916
|
+
*
|
|
917
|
+
* Selective, not a blanket merge:
|
|
918
|
+
* - a path the incoming content provides always wins;
|
|
919
|
+
* - a path the previous write applied and the cloud has now dropped stays deleted,
|
|
920
|
+
* so upstream removals still propagate;
|
|
921
|
+
* - anything else is local and is kept.
|
|
922
|
+
*
|
|
923
|
+
* With no record of the previous write (state written before this existed) nothing
|
|
924
|
+
* can be attributed to the cloud, so local files are kept rather than destroyed.
|
|
925
|
+
*/
|
|
926
|
+
async function preserveUnmanagedFiles(
|
|
927
|
+
existingDir: string,
|
|
928
|
+
stagingDir: string,
|
|
929
|
+
previouslyApplied?: readonly string[],
|
|
930
|
+
): Promise<void> {
|
|
931
|
+
const managed = previouslyApplied ? new Set(previouslyApplied) : null;
|
|
932
|
+
for (const existingFile of await listFilesRecursive(existingDir)) {
|
|
933
|
+
const relativePath = toPosixRelativePath(existingDir, existingFile);
|
|
934
|
+
if (managed?.has(relativePath)) continue;
|
|
935
|
+
const stagedPath = path.join(stagingDir, ...relativePath.split("/"));
|
|
936
|
+
if (await pathExists(stagedPath)) continue;
|
|
937
|
+
await fs.mkdir(path.dirname(stagedPath), { recursive: true });
|
|
938
|
+
await fs.copyFile(existingFile, stagedPath);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
897
942
|
async function pathExists(targetPath: string): Promise<boolean> {
|
|
898
943
|
try {
|
|
899
944
|
await fs.access(targetPath);
|
|
@@ -906,6 +951,7 @@ async function pathExists(targetPath: string): Promise<boolean> {
|
|
|
906
951
|
async function replaceSkillDirectoryAtomically(
|
|
907
952
|
skillDir: string,
|
|
908
953
|
populateDir: (stagingDir: string) => Promise<void>,
|
|
954
|
+
preserve?: { previouslyApplied?: readonly string[] },
|
|
909
955
|
): Promise<void> {
|
|
910
956
|
const parentDir = path.dirname(skillDir);
|
|
911
957
|
const skillName = path.basename(skillDir);
|
|
@@ -926,6 +972,10 @@ async function replaceSkillDirectoryAtomically(
|
|
|
926
972
|
try {
|
|
927
973
|
await populateDir(stagingDir);
|
|
928
974
|
|
|
975
|
+
if (preserve && (await pathExists(skillDir))) {
|
|
976
|
+
await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied);
|
|
977
|
+
}
|
|
978
|
+
|
|
929
979
|
if (await pathExists(skillDir)) {
|
|
930
980
|
await fs.rename(skillDir, backupDir);
|
|
931
981
|
movedExisting = true;
|
|
@@ -986,11 +1036,13 @@ export async function writeCloudSkillToDisk(
|
|
|
986
1036
|
skill: CloudSkill,
|
|
987
1037
|
bundleBytes?: Buffer,
|
|
988
1038
|
paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
|
|
989
|
-
|
|
1039
|
+
options: { previouslyApplied?: readonly string[] } = {},
|
|
1040
|
+
): Promise<SkillWriteOutcome | false> {
|
|
990
1041
|
const skillDir = path.join(
|
|
991
1042
|
paths.skillsDir,
|
|
992
1043
|
safeName(skill.name, paths.skillsDir),
|
|
993
1044
|
);
|
|
1045
|
+
const preserve = { previouslyApplied: options.previouslyApplied };
|
|
994
1046
|
|
|
995
1047
|
if (bundleBytes?.length) {
|
|
996
1048
|
const bundlePath = path.join(
|
|
@@ -999,8 +1051,11 @@ export async function writeCloudSkillToDisk(
|
|
|
999
1051
|
);
|
|
1000
1052
|
try {
|
|
1001
1053
|
await fs.writeFile(bundlePath, bundleBytes);
|
|
1002
|
-
|
|
1003
|
-
|
|
1054
|
+
let appliedFiles: string[] = [];
|
|
1055
|
+
await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
|
|
1056
|
+
appliedFiles = await extractZipToDirectory(bundlePath, stagingDir);
|
|
1057
|
+
}, preserve);
|
|
1058
|
+
return { written: true, appliedFiles };
|
|
1004
1059
|
} finally {
|
|
1005
1060
|
await fs.rm(bundlePath, { force: true });
|
|
1006
1061
|
}
|
|
@@ -1019,6 +1074,7 @@ export async function writeCloudSkillToDisk(
|
|
|
1019
1074
|
`Synced bundle for "${skill.name}" is missing SKILL.md or SKILLS.md`,
|
|
1020
1075
|
);
|
|
1021
1076
|
}
|
|
1077
|
+
const appliedFiles: string[] = [];
|
|
1022
1078
|
await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
|
|
1023
1079
|
for (const bundleFile of skill.bundle_files || []) {
|
|
1024
1080
|
const filePath = resolveSkillBundleFilePath(
|
|
@@ -1030,9 +1086,10 @@ export async function writeCloudSkillToDisk(
|
|
|
1030
1086
|
filePath,
|
|
1031
1087
|
Buffer.from(bundleFile.content_b64, "base64"),
|
|
1032
1088
|
);
|
|
1089
|
+
appliedFiles.push(toPosixRelativePath(stagingDir, filePath));
|
|
1033
1090
|
}
|
|
1034
|
-
});
|
|
1035
|
-
return true;
|
|
1091
|
+
}, preserve);
|
|
1092
|
+
return { written: true, appliedFiles: appliedFiles.sort() };
|
|
1036
1093
|
}
|
|
1037
1094
|
|
|
1038
1095
|
await replaceSkillDirectoryAtomically(skillDir, async (stagingDir) => {
|
|
@@ -1041,6 +1098,6 @@ export async function writeCloudSkillToDisk(
|
|
|
1041
1098
|
skill.skill_md ?? "",
|
|
1042
1099
|
"utf8",
|
|
1043
1100
|
);
|
|
1044
|
-
});
|
|
1045
|
-
return true;
|
|
1101
|
+
}, preserve);
|
|
1102
|
+
return { written: true, appliedFiles: ["SKILL.md"] };
|
|
1046
1103
|
}
|
|
@@ -14,6 +14,10 @@ export interface SyncedSkill {
|
|
|
14
14
|
cloudUpdatedAt?: string;
|
|
15
15
|
/** Content accepted on disk; independent of server-specific folder hash formats. */
|
|
16
16
|
cloudContentHash?: string;
|
|
17
|
+
/** Relative paths the last successful write put in this folder. Anything else in
|
|
18
|
+
* the folder was created locally (a running skill's output) and survives the next
|
|
19
|
+
* cloud write; anything listed here that the cloud later drops is deleted. */
|
|
20
|
+
appliedFiles?: string[];
|
|
17
21
|
/** `skill_folder_hash` of the cloud revision a successful write actually applied.
|
|
18
22
|
* The server may compute that hash with a different construction than the local
|
|
19
23
|
* folder hash (app-published skills do), so only cloud-to-cloud comparison tells
|
|
@@ -28,6 +32,13 @@ export interface NotisSyncState {
|
|
|
28
32
|
skills: Record<string, SyncedSkill>;
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
/** A successful write, plus the paths it put on disk so the next one can tell
|
|
36
|
+
* cloud-managed files apart from whatever ran inside the folder since. */
|
|
37
|
+
export interface SkillWriteOutcome {
|
|
38
|
+
written: true;
|
|
39
|
+
appliedFiles: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
export interface LocalSkill {
|
|
32
43
|
name: string;
|
|
33
44
|
skillMd: string;
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
import type { CloudSkill } from './types';
|
|
1
|
+
import type { CloudSkill, SkillWriteOutcome } from './types';
|
|
2
2
|
|
|
3
3
|
interface WriteCloudSkillDependencies {
|
|
4
4
|
downloadSkillBundle: (bundleUrl: string) => Promise<Buffer>;
|
|
5
|
-
writeCloudSkillToDisk: (skill: CloudSkill, bundleBytes?: Buffer) => Promise<boolean>;
|
|
5
|
+
writeCloudSkillToDisk: (skill: CloudSkill, bundleBytes?: Buffer) => Promise<SkillWriteOutcome | boolean>;
|
|
6
6
|
onWarning?: (message: string, error: unknown) => void;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export async function writeCloudSkillWithBundleFallback(
|
|
10
10
|
skill: CloudSkill,
|
|
11
11
|
dependencies: WriteCloudSkillDependencies,
|
|
12
|
-
): Promise<boolean> {
|
|
12
|
+
): Promise<SkillWriteOutcome | boolean> {
|
|
13
13
|
if (skill.skill_source_url) {
|
|
14
14
|
try {
|
|
15
15
|
const bundleBytes = await dependencies.downloadSkillBundle(skill.skill_source_url);
|
|
16
16
|
const wroteBundleToDisk = await dependencies.writeCloudSkillToDisk(skill, bundleBytes);
|
|
17
17
|
if (wroteBundleToDisk) {
|
|
18
|
-
return
|
|
18
|
+
return wroteBundleToDisk;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
dependencies.onWarning?.(
|