@notis_ai/cli 0.2.0-beta.136.1 → 0.2.0-beta.139.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.
Files changed (36) hide show
  1. package/README.md +38 -0
  2. package/dist/agent-hooks/notis-agent-hook.mjs +16620 -0
  3. package/{skills → dist/base-skills}/notis-apps/SKILL.md +9 -6
  4. package/{skills → dist/base-skills}/notis-cli/SKILL.md +1 -1
  5. package/dist/base-skills/notis-query/SKILL.md +705 -0
  6. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +8 -0
  7. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +8 -0
  8. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +8 -0
  9. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +8 -0
  10. package/dist/skill-sync/index.js +1528 -0
  11. package/dist/skill-sync/index.js.map +7 -0
  12. package/package.json +4 -1
  13. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  14. package/skills/notis-onboarding/BRIEF.md +16 -0
  15. package/src/agent-hook-entry.js +5 -0
  16. package/src/cli.js +23 -14
  17. package/src/command-specs/agents.js +392 -0
  18. package/src/command-specs/auth.js +16 -0
  19. package/src/command-specs/index.js +6 -0
  20. package/src/command-specs/onboarding.js +59 -2
  21. package/src/command-specs/skills.js +56 -0
  22. package/src/runtime/agent-memory-state.js +126 -0
  23. package/src/runtime/agent-setup.js +383 -0
  24. package/src/runtime/base-skills.d.ts +20 -0
  25. package/src/runtime/base-skills.js +167 -0
  26. package/src/runtime/skill-sync/cloud-client.ts +96 -0
  27. package/src/runtime/skill-sync/index.ts +644 -0
  28. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  29. package/src/runtime/skill-sync/symlink-manager.ts +383 -0
  30. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  31. package/src/runtime/skill-sync/types.ts +103 -0
  32. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  33. package/src/runtime/store-screenshot.js +6 -1
  34. package/src/runtime/sync-skills.d.ts +37 -0
  35. package/src/runtime/sync-skills.js +215 -0
  36. package/template/packages/sdk/src/config.ts +8 -0
@@ -0,0 +1,215 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+
6
+ import { reconcileBaseSkills } from './base-skills.js';
7
+
8
+ const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
9
+ const DEFAULT_LOCK_STALE_MS = 10 * 60_000;
10
+ const DEFAULT_LOCK_POLL_MS = 50;
11
+
12
+ async function lockSnapshot(lockDirectory) {
13
+ try {
14
+ const [raw, metadata] = await Promise.all([
15
+ readFile(join(lockDirectory, 'owner'), 'utf8').catch(() => ''),
16
+ stat(lockDirectory),
17
+ ]);
18
+ let owner = {};
19
+ try {
20
+ owner = raw ? JSON.parse(raw) : {};
21
+ } catch {
22
+ // A process can die between mkdir and the atomic owner write. Preserve
23
+ // the directory mtime as a reclaimable ownerless lease.
24
+ }
25
+ return {
26
+ id: typeof owner.id === 'string' ? owner.id : null,
27
+ pid: Number.isInteger(Number(owner.pid)) ? Number(owner.pid) : null,
28
+ at: Number.isFinite(Number(owner.at)) ? Number(owner.at) : metadata.mtimeMs,
29
+ mtimeMs: metadata.mtimeMs,
30
+ };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function sameLockSnapshot(left, right) {
37
+ return Boolean(
38
+ left
39
+ && right
40
+ && left.id === right.id
41
+ && left.pid === right.pid
42
+ && left.at === right.at
43
+ && left.mtimeMs === right.mtimeMs,
44
+ );
45
+ }
46
+
47
+ function processIsAlive(pid) {
48
+ if (!Number.isInteger(pid) || pid <= 0) return false;
49
+ try {
50
+ process.kill(pid, 0);
51
+ return true;
52
+ } catch (error) {
53
+ return error?.code === 'EPERM';
54
+ }
55
+ }
56
+
57
+ function delay(milliseconds) {
58
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
59
+ }
60
+
61
+ async function quarantineStaleLock(lockDirectory, snapshot) {
62
+ const quarantineRoot = join(dirname(lockDirectory), '.stale-operation-locks');
63
+ const snapshotToken = createHash('sha256')
64
+ .update(JSON.stringify(snapshot))
65
+ .digest('hex');
66
+ await mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
67
+ try {
68
+ // The deterministic destination is the compare-and-swap guard. If two
69
+ // waiters observed the same stale owner, only one can move it here. The
70
+ // retained non-empty tombstone prevents the loser from ever moving a new
71
+ // live lock that appeared at the shared pathname in the meantime.
72
+ await rename(lockDirectory, join(quarantineRoot, snapshotToken));
73
+ return true;
74
+ } catch (error) {
75
+ if (['EEXIST', 'ENOTEMPTY', 'ENOENT'].includes(error?.code)) return false;
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ async function writeLockOwnerAtomically(lockDirectory, owner) {
81
+ const temporaryOwnerPath = join(lockDirectory, `.owner.${owner.id}.tmp`);
82
+ const ownerPath = join(lockDirectory, 'owner');
83
+ await writeFile(temporaryOwnerPath, JSON.stringify(owner), { mode: 0o600 });
84
+ await rename(temporaryOwnerPath, ownerPath);
85
+ }
86
+
87
+ /** Serialize Desktop and terminal skill sync across processes on one Mac. */
88
+ export async function withSkillSyncLock(callback, {
89
+ home = homedir(),
90
+ timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
91
+ staleMs = DEFAULT_LOCK_STALE_MS,
92
+ pollMs = DEFAULT_LOCK_POLL_MS,
93
+ now = () => Date.now(),
94
+ } = {}) {
95
+ const lockDirectory = join(home, '.notis', 'skills', '.operation-lock');
96
+ const ownerId = `${process.pid}.${randomUUID()}`;
97
+ const deadline = now() + timeoutMs;
98
+ await mkdir(dirname(lockDirectory), { recursive: true, mode: 0o700 });
99
+
100
+ for (;;) {
101
+ try {
102
+ await mkdir(lockDirectory, { mode: 0o700 });
103
+ try {
104
+ await writeLockOwnerAtomically(lockDirectory, {
105
+ id: ownerId,
106
+ pid: process.pid,
107
+ at: now(),
108
+ });
109
+ } catch (error) {
110
+ // This process exclusively created the directory and has not yet
111
+ // published an owner, so it is safe to undo a failed acquisition.
112
+ await rm(lockDirectory, { recursive: true, force: true });
113
+ throw error;
114
+ }
115
+ break;
116
+ } catch (error) {
117
+ if (error?.code !== 'EEXIST') throw error;
118
+ }
119
+
120
+ const observed = await lockSnapshot(lockDirectory);
121
+ if (
122
+ observed
123
+ && now() - observed.at > staleMs
124
+ && !processIsAlive(observed.pid)
125
+ ) {
126
+ // Require an unchanged observation interval before stealing. This avoids
127
+ // racing a holder that is publishing or refreshing its owner lease.
128
+ await delay(Math.max(pollMs, 10));
129
+ const current = await lockSnapshot(lockDirectory);
130
+ if (sameLockSnapshot(observed, current) && !processIsAlive(current?.pid)) {
131
+ if (await quarantineStaleLock(lockDirectory, current)) continue;
132
+ }
133
+ }
134
+ if (now() >= deadline) {
135
+ throw new Error('Timed out waiting for another Notis skill sync to finish.');
136
+ }
137
+ await delay(pollMs);
138
+ }
139
+
140
+ const heartbeatMs = Math.max(10, Math.min(30_000, Math.floor(staleMs / 3)));
141
+ let heartbeatStopped = false;
142
+ let heartbeatInFlight = Promise.resolve();
143
+ const refreshHeartbeat = async () => {
144
+ if (heartbeatStopped) return;
145
+ const temporaryOwnerPath = join(lockDirectory, `.owner.${ownerId}.tmp`);
146
+ await writeFile(
147
+ temporaryOwnerPath,
148
+ JSON.stringify({ id: ownerId, pid: process.pid, at: now() }),
149
+ { mode: 0o600 },
150
+ );
151
+ const owner = await lockSnapshot(lockDirectory);
152
+ if (owner?.id !== ownerId) {
153
+ await rm(temporaryOwnerPath, { force: true });
154
+ return;
155
+ }
156
+ // If a stale-lock reclaimer moved this directory after the ownership
157
+ // check, the source path disappears and rename fails instead of
158
+ // overwriting the owner of a newly acquired lock at the shared path.
159
+ await rename(temporaryOwnerPath, join(lockDirectory, 'owner'));
160
+ };
161
+ const heartbeat = setInterval(() => {
162
+ // Serialize refreshes and retain the active promise so cleanup cannot race
163
+ // a temporary owner write that was already in flight when the interval was
164
+ // cleared.
165
+ heartbeatInFlight = heartbeatInFlight
166
+ .then(refreshHeartbeat)
167
+ .catch(() => undefined);
168
+ }, heartbeatMs);
169
+ heartbeat.unref?.();
170
+
171
+ try {
172
+ return await callback();
173
+ } finally {
174
+ heartbeatStopped = true;
175
+ clearInterval(heartbeat);
176
+ await heartbeatInFlight;
177
+ const owner = await lockSnapshot(lockDirectory);
178
+ if (owner?.id === ownerId) {
179
+ await rm(lockDirectory, { recursive: true, force: true });
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Shared CLI-owned orchestration for base-skill installation plus account sync.
186
+ * Desktop injects the source engine that webpack bundles; the CLI command
187
+ * injects the generated dist engine used by the published package.
188
+ */
189
+ export async function reconcileAllSkills({
190
+ serverUrl,
191
+ jwt,
192
+ honorSyncEnabled,
193
+ userId = null,
194
+ home,
195
+ runAccountSync,
196
+ reconcileBase = reconcileBaseSkills,
197
+ lockOptions = {},
198
+ }) {
199
+ return withSkillSyncLock(async () => {
200
+ const base = reconcileBase({ userId, ...(home ? { home } : {}) });
201
+ const account = await runAccountSync(
202
+ serverUrl,
203
+ jwt,
204
+ {},
205
+ { honorSyncEnabled },
206
+ );
207
+ return {
208
+ ...account,
209
+ baseSkills: base.skills,
210
+ baseInstalled: base.installed,
211
+ baseLinked: base.linked,
212
+ baseBackups: base.backups,
213
+ };
214
+ }, { ...lockOptions, ...(home ? { home } : {}) });
215
+ }
@@ -209,6 +209,14 @@ export interface NotisAppConfig {
209
209
  */
210
210
  capabilities?: NotisAppCapabilities;
211
211
  routes?: NotisRouteConfig[];
212
+ /**
213
+ * Final tool names this app can call at runtime, enforced server-side. Use
214
+ * names returned by shared discovery, including native `LOCAL_NOTIS_*`,
215
+ * connected-service names such as `GMAIL_SEND_EMAIL`,
216
+ * `LOCAL_POSTFORME_*`, and `LOCAL_MCP_<SERVER>_<TOOL>`. App code calls
217
+ * each declared name directly with `useTool`; metered calls use the shared
218
+ * credit-cap and usage-billing path.
219
+ */
212
220
  tools?: string[];
213
221
  /** Skills shipped from this app's source tree. */
214
222
  skills?: NotisAppSkillConfig[];