@notis_ai/cli 0.2.0-beta.154.1 → 0.2.0-beta.156.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 (30) hide show
  1. package/dist/agent-hooks/notis-agent-hook.mjs +209 -110
  2. package/dist/base-skills/notis-apps/SKILL.md +33 -30
  3. package/dist/skill-sync/index.js +134 -72
  4. package/dist/skill-sync/index.js.map +2 -2
  5. package/package.json +1 -1
  6. package/src/command-specs/apps.js +4 -2
  7. package/src/command-specs/skills.js +3 -2
  8. package/src/command-specs/tools.js +5 -0
  9. package/src/runtime/app-dev-build-supervisor.js +47 -0
  10. package/src/runtime/app-dev-build.js +41 -0
  11. package/src/runtime/app-dev-server.js +2 -6
  12. package/src/runtime/skill-sync/cloud-client.ts +5 -3
  13. package/src/runtime/skill-sync/index.ts +73 -65
  14. package/src/runtime/skill-sync/symlink-manager.ts +66 -16
  15. package/src/runtime/skill-sync/types.ts +5 -0
  16. package/template/app/page.tsx +8 -7
  17. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +49 -8
  18. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  19. package/template/packages/sdk/src/hooks/useCloudComputer.ts +15 -48
  20. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +17 -53
  21. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
  22. package/template/packages/sdk/src/hooks/useDocument.ts +12 -47
  23. package/template/packages/sdk/src/hooks/useDocuments.ts +18 -58
  24. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  25. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  26. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +15 -7
  27. package/template/packages/sdk/src/index.ts +8 -0
  28. package/template/packages/sdk/src/interactions/shortcuts.tsx +1 -1
  29. package/template/packages/sdk/src/queryCache.ts +162 -0
  30. package/template/packages/sdk/src/runtime.ts +5 -0
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect } from 'react';
3
+ import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
4
4
  import { useNotisRuntime } from '../provider';
5
5
 
6
6
  interface TopBarSearchOptions {
@@ -24,8 +24,9 @@ interface TopBarSearchActions {
24
24
  * While the component is mounted, typing in the top bar calls `onChange`,
25
25
  * pressing Enter calls `onSubmit`, and the `value` prop drives the input.
26
26
  *
27
- * Call `setLoading(true)` to show the standard spinner in the top bar while a
28
- * query is running; `setLoading(false)` restores the search icon.
27
+ * Use `setLoading(true)` only for an explicitly submitted search action, then
28
+ * restore the icon with `setLoading(false)`. Automatic reads use content
29
+ * skeletons initially and refresh populated content silently.
29
30
  *
30
31
  * Without a portal runtime, this hook is a safe no-op.
31
32
  *
@@ -42,21 +43,28 @@ interface TopBarSearchActions {
42
43
  * });
43
44
  * ```
44
45
  *
45
- * Stabilize `onChange` and `onSubmit` with `useCallback` to avoid re-registering
46
- * on every render.
46
+ * Callback refs retain the latest committed handlers without re-registering
47
+ * search on every app render. Inline callbacks are safe.
47
48
  */
48
49
  export function useTopBarSearch(opts: TopBarSearchOptions): TopBarSearchActions {
49
50
  const runtime = useNotisRuntime();
50
51
  const { value, onChange, placeholder, onSubmit } = opts;
52
+ const callbacks = useRef({ onChange, onSubmit });
53
+ useLayoutEffect(() => { callbacks.current = { onChange, onSubmit }; });
54
+ const hasSubmit = Boolean(onSubmit);
51
55
 
52
56
  useEffect(() => {
53
57
  const register = runtime?.registerTopBarSearch;
54
58
  if (!register) return;
55
- register({ onChange, placeholder, onSubmit });
59
+ register({
60
+ onChange: (next) => callbacks.current.onChange(next),
61
+ placeholder,
62
+ onSubmit: hasSubmit ? () => callbacks.current.onSubmit?.() : undefined,
63
+ });
56
64
  return () => {
57
65
  register(null);
58
66
  };
59
- }, [runtime, onChange, placeholder, onSubmit]);
67
+ }, [runtime, placeholder, hasSubmit]);
60
68
 
61
69
  useEffect(() => {
62
70
  runtime?.setTopBarSearchValue?.(value);
@@ -10,6 +10,10 @@
10
10
  export { NotisProvider, useNotisRuntime } from './provider';
11
11
 
12
12
  // Hooks
13
+ export { useQuery, useQueryClient } from './hooks/useQuery';
14
+ export type { UseQueryOptions, UseQueryResult } from './hooks/useQuery';
15
+ export { createQueryClient, createPrefetchQueue, queryKey } from './queryCache';
16
+ export type { NotisQueryClient, QuerySnapshot, QueryFetchOptions } from './queryCache';
13
17
  export { useNotis } from './hooks/useNotis';
14
18
  export { useDocuments } from './hooks/useDocuments';
15
19
  export type { UseDocumentsOptions, UseDocumentsResult } from './hooks/useDocuments';
@@ -143,3 +147,7 @@ export type {
143
147
  ToolCallOptions,
144
148
  ToolInputSchema,
145
149
  } from './runtime';
150
+
151
+ export { useToolQuery } from './hooks/useToolQuery';
152
+
153
+ export { Skeleton, ViewSkeleton } from './components/Skeleton';
@@ -186,7 +186,7 @@ export function isEditableShortcutEvent(event: Event): boolean {
186
186
  return true;
187
187
  }
188
188
  if (tag === 'A' && target.hasAttribute('href')) return true;
189
- return ['button', 'link', 'menuitem', 'option', 'switch', 'tab'].includes(
189
+ return ['button', 'link', 'menuitem', 'switch', 'tab'].includes(
190
190
  target.getAttribute('role') || '',
191
191
  );
192
192
  });
@@ -0,0 +1,162 @@
1
+ /** In-memory read snapshots. The host owns the lifetime and authorization scope. */
2
+ export interface QuerySnapshot<T = unknown> {
3
+ data: T | undefined;
4
+ hasData: boolean;
5
+ isFetching: boolean;
6
+ error: Error | null;
7
+ updatedAt: number;
8
+ invalidation: number;
9
+ }
10
+
11
+ export interface QueryFetchOptions {
12
+ staleTimeMs?: number;
13
+ force?: boolean;
14
+ }
15
+
16
+ export interface NotisQueryClient {
17
+ getSnapshot<T>(key: string): QuerySnapshot<T>;
18
+ subscribe(key: string, listener: () => void): () => void;
19
+ fetch<T>(key: string, read: () => Promise<T>, options?: QueryFetchOptions): Promise<T>;
20
+ prefetch<T>(key: string, read: () => Promise<T>, options?: QueryFetchOptions): Promise<T>;
21
+ invalidate(key?: string): void;
22
+ clear(): void;
23
+ /** Host-only: permanently retire a revoked security scope. */
24
+ dispose?(): void;
25
+ getRevision?(): number;
26
+ }
27
+
28
+ const EMPTY: QuerySnapshot = Object.freeze({
29
+ data: undefined, hasData: false, isFetching: false, error: null, updatedAt: 0, invalidation: 0,
30
+ });
31
+
32
+ /** Stable keys distinguish filters, pagination, and resource identities. */
33
+ export function queryKey(value: unknown): string {
34
+ if (value instanceof Date) return JSON.stringify(value.toISOString());
35
+ if (Array.isArray(value)) return `[${value.map(queryKey).join(',')}]`;
36
+ if (value && typeof value === 'object') {
37
+ const record = value as Record<string, unknown>;
38
+ return `{${Object.keys(record).filter((key) => record[key] !== undefined).sort()
39
+ .map((key) => `${JSON.stringify(key)}:${queryKey(record[key])}`).join(',')}}`;
40
+ }
41
+ return JSON.stringify(value) ?? 'null';
42
+ }
43
+
44
+ /** One shared queue can bound descriptor and data speculation together. */
45
+ export function createPrefetchQueue(concurrency = 2) {
46
+ let active = 0;
47
+ const waiting: Array<() => void> = [];
48
+ function drain() {
49
+ while (active < concurrency && waiting.length) waiting.shift()!();
50
+ }
51
+ return <T>(read: () => Promise<T>): Promise<T> => new Promise<T>((resolve, reject) => {
52
+ waiting.push(() => {
53
+ active += 1;
54
+ Promise.resolve().then(read).then(resolve, reject).finally(() => { active -= 1; drain(); });
55
+ });
56
+ drain();
57
+ });
58
+ }
59
+
60
+ type Entry = {
61
+ snapshot: QuerySnapshot;
62
+ listeners: Set<() => void>;
63
+ generation: number;
64
+ pending?: Promise<unknown>;
65
+ };
66
+
67
+ export function createQueryClient(options: {
68
+ maxEntries?: number;
69
+ now?: () => number;
70
+ schedule?: ReturnType<typeof createPrefetchQueue>;
71
+ } = {}): NotisQueryClient {
72
+ const entries = new Map<string, Entry>();
73
+ const now = options.now ?? Date.now;
74
+ const maxEntries = options.maxEntries ?? 100;
75
+ const schedule = options.schedule ?? createPrefetchQueue(2);
76
+ let epoch = 0;
77
+ let disposed = false;
78
+ let revision = 0;
79
+
80
+ function prune(keep?: string) {
81
+ for (const [oldKey, old] of entries) {
82
+ if (entries.size <= maxEntries) break;
83
+ if (oldKey !== keep && !old.listeners.size && !old.pending) entries.delete(oldKey);
84
+ }
85
+ }
86
+ function entry(key: string): Entry {
87
+ let value = entries.get(key);
88
+ if (!value) {
89
+ value = { snapshot: EMPTY, listeners: new Set(), generation: 0 };
90
+ entries.set(key, value);
91
+ }
92
+ // Only operations, not getSnapshot during render, update the LRU.
93
+ entries.delete(key);
94
+ entries.set(key, value);
95
+ prune(key);
96
+ return value;
97
+ }
98
+ function publish(value: Entry, snapshot: QuerySnapshot) {
99
+ value.snapshot = snapshot;
100
+ for (const listener of value.listeners) listener();
101
+ }
102
+ const client: NotisQueryClient = {
103
+ getSnapshot<T>(key: string) { return (entries.get(key)?.snapshot ?? EMPTY) as QuerySnapshot<T>; },
104
+ subscribe(key, listener) {
105
+ const value = entry(key);
106
+ value.listeners.add(listener);
107
+ return () => { value.listeners.delete(listener); prune(); };
108
+ },
109
+ fetch<T>(key: string, read: () => Promise<T>, fetchOptions: QueryFetchOptions = {}) {
110
+ if (disposed) return Promise.reject(new Error('App query scope is no longer available.'));
111
+ const value = entry(key);
112
+ if (value.pending) return value.pending as Promise<T>;
113
+ if (!fetchOptions.force && value.snapshot.hasData && value.snapshot.updatedAt > 0
114
+ && now() - value.snapshot.updatedAt < (fetchOptions.staleTimeMs ?? 30_000)) {
115
+ return Promise.resolve(value.snapshot.data as T);
116
+ }
117
+ const generation = ++value.generation;
118
+ const requestEpoch = epoch;
119
+ const canCommit = () => epoch === requestEpoch && value.generation === generation && entries.get(key) === value;
120
+ const request = Promise.resolve().then(read).then((data) => {
121
+ if (canCommit()) publish(value, { ...value.snapshot, data, hasData: true, isFetching: false, error: null, updatedAt: now() });
122
+ return data;
123
+ }, (reason) => {
124
+ const error = reason instanceof Error ? reason : new Error(String(reason));
125
+ if (canCommit()) publish(value, { ...value.snapshot, isFetching: false, error });
126
+ throw error;
127
+ }).finally(() => { if (value.pending === request) value.pending = undefined; prune(); });
128
+ value.pending = request;
129
+ publish(value, { ...value.snapshot, isFetching: true, error: null });
130
+ return request;
131
+ },
132
+ prefetch<T>(key: string, read: () => Promise<T>, fetchOptions?: QueryFetchOptions) {
133
+ const requestEpoch = epoch;
134
+ return schedule(() => {
135
+ if (epoch !== requestEpoch) throw new Error('App query scope was cleared.');
136
+ return client.fetch(key, read, fetchOptions);
137
+ });
138
+ },
139
+ getRevision: () => revision,
140
+ invalidate(key) {
141
+ revision += 1;
142
+ for (const [entryKey, value] of entries) {
143
+ if (key !== undefined && entryKey !== key) continue;
144
+ value.generation += 1;
145
+ value.pending = undefined;
146
+ publish(value, { ...value.snapshot, updatedAt: 0, isFetching: false, invalidation: value.snapshot.invalidation + 1 });
147
+ }
148
+ },
149
+ dispose() { disposed = true; client.clear(); },
150
+ clear() {
151
+ revision += 1;
152
+ epoch += 1;
153
+ for (const value of entries.values()) {
154
+ value.generation += 1;
155
+ value.pending = undefined;
156
+ publish(value, { ...EMPTY, invalidation: value.snapshot.invalidation + 1 });
157
+ }
158
+ for (const [key, value] of entries) if (!value.listeners.size) entries.delete(key);
159
+ },
160
+ };
161
+ return client;
162
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ComponentType } from 'react';
11
+ import type { NotisQueryClient } from './queryCache';
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // Database types
@@ -106,6 +107,8 @@ export interface ToolDescriptor {
106
107
  }
107
108
 
108
109
  export interface ToolCallOptions {
110
+ /** Explicitly identifies an idempotent read; never set on a mutation. */
111
+ readOnly?: boolean;
109
112
  /**
110
113
  * Coalesce an identical in-flight call. Only opt in for idempotent reads;
111
114
  * mutations must execute once per invocation.
@@ -359,6 +362,8 @@ export interface CloudComputerFacts {
359
362
  }
360
363
 
361
364
  export interface NotisRuntime {
365
+ /** Optional host-scoped in-memory read cache. Older hosts remain supported. */
366
+ queryClient?: NotisQueryClient;
362
367
  app: AppDescriptor;
363
368
  route: RouteDescriptor;
364
369
  databases: DatabaseDescriptor[];