vite-plugin-agentation-vue 0.0.2

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 (56) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +116 -0
  3. package/dist/index.d.ts +23 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +23 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/plugin.d.ts +28 -0
  8. package/dist/plugin.d.ts.map +1 -0
  9. package/dist/plugin.js +155 -0
  10. package/dist/plugin.js.map +1 -0
  11. package/dist/runtime/bootstrap.d.ts +27 -0
  12. package/dist/runtime/bootstrap.d.ts.map +1 -0
  13. package/dist/runtime/bootstrap.js +462 -0
  14. package/dist/runtime/bootstrap.js.map +1 -0
  15. package/dist/runtime/entry.d.ts +13 -0
  16. package/dist/runtime/entry.d.ts.map +1 -0
  17. package/dist/runtime/entry.js +110 -0
  18. package/dist/runtime/entry.js.map +1 -0
  19. package/dist/runtime/resolver/index.d.ts +4 -0
  20. package/dist/runtime/resolver/index.d.ts.map +1 -0
  21. package/dist/runtime/resolver/index.js +4 -0
  22. package/dist/runtime/resolver/index.js.map +1 -0
  23. package/dist/runtime/resolver/resolve-source.d.ts +21 -0
  24. package/dist/runtime/resolver/resolve-source.d.ts.map +1 -0
  25. package/dist/runtime/resolver/resolve-source.js +46 -0
  26. package/dist/runtime/resolver/resolve-source.js.map +1 -0
  27. package/dist/runtime/resolver/tracer-adapter.d.ts +9 -0
  28. package/dist/runtime/resolver/tracer-adapter.d.ts.map +1 -0
  29. package/dist/runtime/resolver/tracer-adapter.js +84 -0
  30. package/dist/runtime/resolver/tracer-adapter.js.map +1 -0
  31. package/dist/runtime/resolver/vue-internal.d.ts +12 -0
  32. package/dist/runtime/resolver/vue-internal.d.ts.map +1 -0
  33. package/dist/runtime/resolver/vue-internal.js +95 -0
  34. package/dist/runtime/resolver/vue-internal.js.map +1 -0
  35. package/dist/runtime/sync.d.ts +12 -0
  36. package/dist/runtime/sync.d.ts.map +1 -0
  37. package/dist/runtime/sync.js +168 -0
  38. package/dist/runtime/sync.js.map +1 -0
  39. package/dist/tsconfig.tsbuildinfo +1 -0
  40. package/dist/types.d.ts +61 -0
  41. package/dist/types.d.ts.map +1 -0
  42. package/dist/types.js +12 -0
  43. package/dist/types.js.map +1 -0
  44. package/dist/virtual.d.ts +37 -0
  45. package/dist/virtual.d.ts.map +1 -0
  46. package/dist/virtual.js +96 -0
  47. package/dist/virtual.js.map +1 -0
  48. package/package.json +50 -0
  49. package/src/client.d.ts +4 -0
  50. package/src/runtime/bootstrap.ts +578 -0
  51. package/src/runtime/entry.ts +146 -0
  52. package/src/runtime/resolver/index.ts +3 -0
  53. package/src/runtime/resolver/resolve-source.ts +51 -0
  54. package/src/runtime/resolver/tracer-adapter.ts +92 -0
  55. package/src/runtime/resolver/vue-internal.ts +113 -0
  56. package/src/runtime/sync.ts +192 -0
@@ -0,0 +1,146 @@
1
+ import { createApp } from "vue"
2
+ import { findTraceFromElement } from "vite-plugin-vue-tracer/client/record"
3
+ import {
4
+ OverlayRoot,
5
+ createAnnotationsStore,
6
+ createAreaSelectionState,
7
+ createSelectionState,
8
+ createSettingsState,
9
+ } from "@liuovo/agentation-vue-ui"
10
+ import type { RuntimeBridge, UiNotification } from "@liuovo/agentation-vue-ui"
11
+ import type { ResolvedAgentationVueOptions } from "../types.js"
12
+ import { bindTracer } from "./resolver/index.js"
13
+ import { setupInfrastructure, attachListeners } from "./bootstrap.js"
14
+ import { createRuntimeSyncBridge } from "./sync.js"
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Browser console logging (styled with %c)
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const badgeStyle = [
21
+ "background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
22
+ "color: #34d399",
23
+ "font-weight: 700",
24
+ "padding: 2px 8px",
25
+ "border-radius: 4px",
26
+ "font-size: 11px",
27
+ ].join(";")
28
+
29
+ const msgStyle = "color: #22d3ee; font-weight: 600;"
30
+ const detailStyle = "color: #a78bfa;"
31
+
32
+ function logBrowser(emoji: string, message: string, detail = ""): void {
33
+ console.log(
34
+ `%c🛰 agentation%c ${emoji} ${message}%c${detail ? ` ${detail}` : ""}`,
35
+ badgeStyle,
36
+ msgStyle,
37
+ detailStyle,
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Runtime entry point — executed in the browser via the virtual module.
43
+ *
44
+ * 1. Disposes any previous runtime instance (supports HMR re-injection)
45
+ * 2. Binds the vue-tracer lookup function into the resolver
46
+ * 3. Sets up DOM containers and storage bridge
47
+ * 4. Creates store, selection, settings, and area selection state
48
+ * 5. Attaches event listeners that drive Vue state
49
+ * 6. Mounts the Vue overlay app
50
+ */
51
+ export function runAgentationRuntime(options: ResolvedAgentationVueOptions): void {
52
+ logBrowser("✨", "Runtime starting", `storagePrefix="${options.storagePrefix}" locale="${options.locale}"`)
53
+
54
+ // Dispose previous instance if re-injected (e.g. HMR)
55
+ window.__agentationRuntime?.dispose()
56
+
57
+ // Bind vue-tracer into the resolver
58
+ const tracerAvailable = typeof findTraceFromElement === "function"
59
+ if (tracerAvailable) {
60
+ logBrowser("🔎", "Binding vue-tracer", "precise source mapping enabled")
61
+ bindTracer(findTraceFromElement)
62
+ } else {
63
+ console.warn(
64
+ "%c🛰 agentation%c ⚠️ vue-tracer not found — using Vue internals fallback",
65
+ badgeStyle,
66
+ "color: #f59e0b; font-weight: 600;",
67
+ )
68
+ }
69
+
70
+ // 1. Infrastructure: DOM containers + storage + source resolver
71
+ logBrowser("🏗️", "Setting up infrastructure", "DOM containers + storage bridge")
72
+ const infra = setupInfrastructure(options.storagePrefix)
73
+ const notificationListeners = new Set<(notification: UiNotification) => void>()
74
+
75
+ // 2. Build the bridge for the UI layer
76
+ const bridge: RuntimeBridge = {
77
+ appRoot: infra.appRoot,
78
+ overlayRoot: infra.overlayRoot,
79
+ options: { outputDetail: options.outputDetail },
80
+ storage: infra.storage,
81
+ sync: options.sync
82
+ ? createRuntimeSyncBridge(options.sync, infra.storage)
83
+ : undefined,
84
+ notify(notification) {
85
+ for (const listener of notificationListeners) {
86
+ listener(notification)
87
+ }
88
+ },
89
+ subscribeNotifications(listener) {
90
+ notificationListeners.add(listener)
91
+ return () => {
92
+ notificationListeners.delete(listener)
93
+ }
94
+ },
95
+ resolveSource: infra.resolveSource,
96
+ }
97
+
98
+ // 3. Create store, selection, settings, and area selection state
99
+ logBrowser("🗂️", "Creating annotations store + selection + settings")
100
+ const store = createAnnotationsStore(bridge)
101
+ const selection = createSelectionState()
102
+ const settings = createSettingsState({
103
+ outputDetail: options.outputDetail,
104
+ locale: options.locale,
105
+ })
106
+ const areaSelection = createAreaSelectionState()
107
+
108
+ // 4. Attach event listeners (pointer/click/drag → store/selection)
109
+ logBrowser("👂", "Attaching event listeners", "pointer + click + drag selection")
110
+ const listeners = attachListeners(
111
+ store,
112
+ selection,
113
+ areaSelection,
114
+ settings,
115
+ infra.resolveSource,
116
+ bridge.notify,
117
+ )
118
+
119
+ // 5. Mount the Vue overlay app
120
+ logBrowser("🪄", "Mounting overlay app")
121
+ const app = createApp(OverlayRoot, {
122
+ bridge,
123
+ store,
124
+ selection,
125
+ settings,
126
+ areaSelection,
127
+ })
128
+ app.mount(infra.appRoot)
129
+
130
+ // 6. Expose runtime context for HMR disposal + console debugging
131
+ window.__agentationRuntime = {
132
+ dispose() {
133
+ app.unmount()
134
+ listeners.dispose()
135
+ areaSelection.clear()
136
+ selection.clearHovered()
137
+ selection.clearSelection()
138
+ infra.appRoot.replaceChildren()
139
+ infra.overlayRoot.replaceChildren()
140
+ delete window.__agentationDemo
141
+ },
142
+ }
143
+
144
+ // Diagnostics
145
+ logBrowser("✅", "Runtime ready!", "Try: __agentationDemo.inspect($0)")
146
+ }
@@ -0,0 +1,3 @@
1
+ export { resolveElementSource, bindTracer } from "./resolve-source.js"
2
+ export { mapTraceToSourceLocation } from "./tracer-adapter.js"
3
+ export { resolveFromVueInternals } from "./vue-internal.js"
@@ -0,0 +1,51 @@
1
+ import type { SourceLocation } from "@liuovo/agentation-vue-core"
2
+ import type { ElementTraceInfo } from "vite-plugin-vue-tracer/client/record"
3
+ import { mapTraceToSourceLocation } from "./tracer-adapter.js"
4
+ import { resolveFromVueInternals } from "./vue-internal.js"
5
+
6
+ type FindTraceFn = (el?: Element | null) => ElementTraceInfo | undefined
7
+
8
+ /**
9
+ * Holder for the lazily-bound vue-tracer lookup function.
10
+ *
11
+ * At dev runtime, the Vite plugin ensures `vite-plugin-vue-tracer/client/record`
12
+ * is loaded before any user interaction. The runtime bootstrap calls
13
+ * `bindTracer()` to wire up the reference once tracer is ready.
14
+ */
15
+ let _findTrace: FindTraceFn | null = null
16
+
17
+ /**
18
+ * Bind the vue-tracer `findTraceFromElement` function.
19
+ *
20
+ * Called once by the runtime bootstrap after importing the tracer client module.
21
+ */
22
+ export function bindTracer(fn: FindTraceFn): void {
23
+ _findTrace = fn
24
+ }
25
+
26
+ /**
27
+ * Unified entry point for resolving an HTML element to its Vue source location.
28
+ *
29
+ * Resolution order:
30
+ * 1. vue-tracer headless API (`findTraceFromElement`) — provides file, line, column
31
+ * 2. Vue internals fallback (`__vnode` / `__vueParentComponent`) — provides file only
32
+ *
33
+ * Returns `null` when neither strategy can identify the source.
34
+ */
35
+ export function resolveElementSource(el: HTMLElement): SourceLocation | null {
36
+ // Strategy 1: vue-tracer (preferred — includes precise line/column)
37
+ if (_findTrace) {
38
+ try {
39
+ const trace = _findTrace(el)
40
+ if (trace) {
41
+ const result = mapTraceToSourceLocation(trace)
42
+ if (result) return result
43
+ }
44
+ } catch {
45
+ // tracer threw — fall through to next strategy
46
+ }
47
+ }
48
+
49
+ // Strategy 2: Vue internals fallback (file-level only, no line/column)
50
+ return resolveFromVueInternals(el)
51
+ }
@@ -0,0 +1,92 @@
1
+ import type { SourceLocation } from "@liuovo/agentation-vue-core"
2
+ import type { ElementTraceInfo } from "vite-plugin-vue-tracer/client/record"
3
+
4
+ /**
5
+ * Maps a vue-tracer `ElementTraceInfo` to the internal `SourceLocation` type.
6
+ *
7
+ * Returns `null` when the trace info lacks the minimum required data (file path).
8
+ */
9
+ export function mapTraceToSourceLocation(trace: ElementTraceInfo): SourceLocation | null {
10
+ const [source, line, column] = trace.pos
11
+ if (!source) return null
12
+
13
+ const componentName = extractComponentName(trace)
14
+ const hierarchy = buildComponentHierarchy(trace)
15
+
16
+ return {
17
+ framework: "vue",
18
+ componentName,
19
+ componentHierarchy: hierarchy || undefined,
20
+ file: source,
21
+ line,
22
+ column,
23
+ resolver: "vue-tracer",
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Extracts the component display name from a trace entry.
29
+ *
30
+ * Walks the vnode tree to find the nearest component boundary.
31
+ * Falls back to the file basename when no component name is available.
32
+ */
33
+ function extractComponentName(trace: ElementTraceInfo): string {
34
+ const vnode = trace.vnode
35
+ if (vnode) {
36
+ const type = vnode.type as Record<string, unknown> | undefined
37
+ if (type) {
38
+ if (typeof type === "object" && typeof type.name === "string" && type.name) {
39
+ return type.name
40
+ }
41
+ if (typeof type === "object" && typeof type.__name === "string" && type.__name) {
42
+ return type.__name
43
+ }
44
+ }
45
+ }
46
+
47
+ // Fallback: derive name from file path
48
+ const [source] = trace.pos
49
+ if (source) {
50
+ const segments = source.split("/")
51
+ const filename = segments[segments.length - 1] ?? source
52
+ return filename.replace(/\.\w+$/, "")
53
+ }
54
+
55
+ return "Unknown"
56
+ }
57
+
58
+ /**
59
+ * Builds a component hierarchy string by walking parent traces.
60
+ *
61
+ * Produces a string like `<App> <Layout> <Button>`.
62
+ * Caps at 10 levels to avoid infinite loops in edge cases.
63
+ */
64
+ function buildComponentHierarchy(trace: ElementTraceInfo): string | null {
65
+ const names: string[] = []
66
+ let current: ElementTraceInfo | undefined = trace
67
+ const maxDepth = 10
68
+
69
+ for (let depth = 0; current && depth < maxDepth; depth++) {
70
+ const name = extractComponentNameFromTrace(current)
71
+ if (name && (names.length === 0 || names[names.length - 1] !== name)) {
72
+ names.push(name)
73
+ }
74
+ current = current.getParent()
75
+ }
76
+
77
+ if (names.length <= 1) return null
78
+ return names.reverse().map((n) => `<${n}>`).join(" ")
79
+ }
80
+
81
+ function extractComponentNameFromTrace(trace: ElementTraceInfo): string | null {
82
+ const vnode = trace.vnode
83
+ if (!vnode) return null
84
+
85
+ const type = vnode.type as Record<string, unknown> | undefined
86
+ if (!type || typeof type !== "object") return null
87
+
88
+ if (typeof type.name === "string" && type.name) return type.name
89
+ if (typeof type.__name === "string" && type.__name) return type.__name
90
+
91
+ return null
92
+ }
@@ -0,0 +1,113 @@
1
+ import type { SourceLocation } from "@liuovo/agentation-vue-core"
2
+ import type { ComponentInternalInstance, VNode } from "vue"
3
+
4
+ /**
5
+ * Fallback resolver using Vue 3 internal properties on DOM elements.
6
+ *
7
+ * Used when vue-tracer data is not available (e.g. tracer plugin not loaded,
8
+ * or element was rendered before tracer initialized).
9
+ *
10
+ * Walks up from the element looking for `__vueParentComponent` or `__vnode`
11
+ * to extract component file information.
12
+ */
13
+ export function resolveFromVueInternals(el: HTMLElement): SourceLocation | null {
14
+ const instance = findComponentInstance(el)
15
+ if (!instance) return null
16
+
17
+ // Walk up the component tree to find the first instance with file metadata
18
+ const resolved = findInstanceWithFile(instance)
19
+ if (!resolved) return null
20
+
21
+ const { instance: fileInstance, file } = resolved
22
+ const type = fileInstance.type as Record<string, unknown>
23
+ const componentName = extractName(type) ?? deriveNameFromFile(file)
24
+
25
+ return {
26
+ framework: "vue",
27
+ componentName,
28
+ componentHierarchy: buildHierarchyFromInstance(fileInstance) || undefined,
29
+ file,
30
+ line: undefined,
31
+ column: undefined,
32
+ resolver: "vue-internal",
33
+ }
34
+ }
35
+
36
+ interface VueElement extends HTMLElement {
37
+ __vueParentComponent?: ComponentInternalInstance
38
+ __vnode?: VNode
39
+ }
40
+
41
+ function findComponentInstance(el: HTMLElement): ComponentInternalInstance | null {
42
+ let current: HTMLElement | null = el
43
+
44
+ while (current) {
45
+ const vueEl = current as VueElement
46
+
47
+ if (vueEl.__vueParentComponent) {
48
+ return vueEl.__vueParentComponent
49
+ }
50
+
51
+ if (vueEl.__vnode) {
52
+ const vnode = vueEl.__vnode
53
+ const type = vnode.type as Record<string, unknown> | undefined
54
+ if (type && typeof type === "object" && extractFile(type)) {
55
+ return { type, parent: null } as unknown as ComponentInternalInstance
56
+ }
57
+ }
58
+
59
+ current = current.parentElement
60
+ }
61
+
62
+ return null
63
+ }
64
+
65
+ function findInstanceWithFile(
66
+ instance: ComponentInternalInstance,
67
+ ): { instance: ComponentInternalInstance; file: string } | null {
68
+ let current: ComponentInternalInstance | null = instance
69
+ const maxDepth = 20
70
+
71
+ for (let depth = 0; current && depth < maxDepth; depth++) {
72
+ const type = current.type as Record<string, unknown>
73
+ const file = extractFile(type)
74
+ if (file) return { instance: current, file }
75
+ current = current.parent
76
+ }
77
+
78
+ return null
79
+ }
80
+
81
+ function extractFile(type: Record<string, unknown>): string | null {
82
+ if (typeof type.__file === "string" && type.__file) return type.__file
83
+ if (typeof type.__filePath === "string" && type.__filePath) return type.__filePath
84
+ return null
85
+ }
86
+
87
+ function extractName(type: Record<string, unknown>): string | null {
88
+ if (typeof type.name === "string" && type.name) return type.name
89
+ if (typeof type.__name === "string" && type.__name) return type.__name
90
+ return null
91
+ }
92
+
93
+ function deriveNameFromFile(file: string): string {
94
+ const segments = file.split("/")
95
+ const filename = segments[segments.length - 1] ?? file
96
+ return filename.replace(/\.\w+$/, "")
97
+ }
98
+
99
+ function buildHierarchyFromInstance(instance: ComponentInternalInstance): string | null {
100
+ const names: string[] = []
101
+ let current: ComponentInternalInstance | null = instance
102
+ const maxDepth = 10
103
+
104
+ for (let depth = 0; current && depth < maxDepth; depth++) {
105
+ const type = current.type as Record<string, unknown>
106
+ const name = extractName(type)
107
+ if (name) names.push(name)
108
+ current = current.parent
109
+ }
110
+
111
+ if (names.length <= 1) return null
112
+ return names.reverse().map((n) => `<${n}>`).join(" ")
113
+ }
@@ -0,0 +1,192 @@
1
+ import {
2
+ createSession as apiCreateSession,
3
+ syncAnnotation,
4
+ updateAnnotation as apiUpdateAnnotation,
5
+ deleteAnnotation as apiDeleteAnnotation,
6
+ } from "@liuovo/agentation-vue-core"
7
+ import {
8
+ getUnsyncedAnnotations,
9
+ loadSessionId,
10
+ markAnnotationsSynced,
11
+ saveSessionId,
12
+ } from "@liuovo/agentation-vue-core"
13
+ import type { AnnotationV2 } from "@liuovo/agentation-vue-core"
14
+ import type { RuntimeSyncBridge } from "@liuovo/agentation-vue-ui"
15
+ import type { AgentationStorageBridge, AgentationVueSyncOptions } from "../types.js"
16
+
17
+ /**
18
+ * Creates a sync bridge that connects the local annotation store
19
+ * to the remote MCP server via the V2 HTTP API.
20
+ *
21
+ * - On init, flushes any unsynced annotations to the server.
22
+ * - On enqueueUpsert, debounces a flush to batch rapid saves.
23
+ * - On enqueueDelete, immediately fires a best-effort DELETE.
24
+ */
25
+ export function createRuntimeSyncBridge(
26
+ sync: AgentationVueSyncOptions,
27
+ storage: AgentationStorageBridge,
28
+ ): RuntimeSyncBridge {
29
+ const pathname = () => window.location.pathname
30
+ const debounceMs = sync.debounceMs ?? 400
31
+
32
+ let sessionId: string | undefined = loadSessionId(pathname(), storage.options) ?? undefined
33
+ let currentPathname = pathname()
34
+ let flushTimer: ReturnType<typeof setTimeout> | undefined
35
+ let flushInFlight: Promise<void> | null = null
36
+ let dirtyWhileFlushing = false
37
+
38
+ /**
39
+ * Re-resolve sessionId when the SPA pathname changes.
40
+ * Called before flush operations to handle client-side routing.
41
+ */
42
+ function watchPathname(): void {
43
+ const current = pathname()
44
+ if (current !== currentPathname) {
45
+ currentPathname = current
46
+ sessionId = loadSessionId(current, storage.options) ?? undefined
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Ensure a server-side session exists for the current page.
52
+ * Creates one lazily and persists the ID in localStorage.
53
+ */
54
+ async function ensureSession(): Promise<string> {
55
+ watchPathname()
56
+ if (sessionId) return sessionId
57
+
58
+ try {
59
+ const session = await apiCreateSession(sync.endpoint, window.location.href)
60
+ sessionId = session.id
61
+ saveSessionId(pathname(), session.id, storage.options)
62
+ return session.id
63
+ } catch (err) {
64
+ console.warn("[agentation] Failed to create session:", err)
65
+ throw err
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Flush all unsynced annotations to the server.
71
+ *
72
+ * Serialized: if a flush is already running and new writes arrive,
73
+ * we mark the state as dirty and re-flush after the current one completes.
74
+ */
75
+ async function flush(): Promise<void> {
76
+ if (flushInFlight) {
77
+ dirtyWhileFlushing = true
78
+ return flushInFlight
79
+ }
80
+
81
+ flushInFlight = (async () => {
82
+ const sid = await ensureSession()
83
+ const pending = getUnsyncedAnnotations(pathname(), sid, storage.options)
84
+
85
+ if (pending.length === 0) return
86
+
87
+ const synced: string[] = []
88
+ for (const annotation of pending) {
89
+ try {
90
+ await syncAnnotation(sync.endpoint, sid, annotation)
91
+ synced.push(annotation.id)
92
+ } catch (err) {
93
+ console.warn("[agentation] Failed to sync annotation:", annotation.id, err)
94
+ }
95
+ }
96
+
97
+ if (synced.length > 0) {
98
+ markAnnotationsSynced(pathname(), synced, sid, storage.options)
99
+ }
100
+ })()
101
+ .catch((err) => {
102
+ console.warn("[agentation] Sync flush failed:", err)
103
+ })
104
+ .finally(() => {
105
+ flushInFlight = null
106
+
107
+ // If new writes arrived while we were flushing, schedule a follow-up
108
+ if (dirtyWhileFlushing) {
109
+ dirtyWhileFlushing = false
110
+ scheduleFlush()
111
+ }
112
+ })
113
+
114
+ return flushInFlight
115
+ }
116
+
117
+ /**
118
+ * Schedule a debounced flush.
119
+ * Resets the timer on each call to batch rapid saves.
120
+ */
121
+ function scheduleFlush(): void {
122
+ if (flushTimer != null) {
123
+ clearTimeout(flushTimer)
124
+ }
125
+ flushTimer = setTimeout(() => {
126
+ flushTimer = undefined
127
+ void flush()
128
+ }, debounceMs)
129
+ }
130
+
131
+ /**
132
+ * Best-effort PATCH for an updated annotation.
133
+ * Reuses the same session; falls back to upsert on error.
134
+ */
135
+ async function updateRemote(annotation: AnnotationV2): Promise<void> {
136
+ watchPathname()
137
+ if (!sessionId) {
138
+ // No session yet — treat as upsert
139
+ scheduleFlush()
140
+ return
141
+ }
142
+
143
+ try {
144
+ await apiUpdateAnnotation(sync.endpoint, annotation.id, annotation)
145
+ } catch (err) {
146
+ console.warn("[agentation] Update sync failed, will re-upsert:", annotation.id, err)
147
+ scheduleFlush()
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Best-effort DELETE for a removed annotation.
153
+ * If the annotation was never synced (404), silently ignore.
154
+ * Does NOT create a session — if no session exists, the annotation
155
+ * was never synced anyway.
156
+ */
157
+ async function deleteRemote(annotation: AnnotationV2): Promise<void> {
158
+ watchPathname()
159
+ // Don't create a session just to delete — if no session exists,
160
+ // the annotation was never synced.
161
+ if (!sessionId) return
162
+
163
+ try {
164
+ await apiDeleteAnnotation(sync.endpoint, annotation.id)
165
+ } catch (err) {
166
+ if (err instanceof Error && err.message.includes("404")) return
167
+ console.warn("[agentation] Delete sync failed:", annotation.id, err)
168
+ }
169
+ }
170
+
171
+ return {
172
+ init() {
173
+ if (sync.autoSync === false) return Promise.resolve()
174
+ return flush()
175
+ },
176
+
177
+ enqueueUpsert(_annotation: AnnotationV2) {
178
+ if (sync.autoSync === false) return
179
+ scheduleFlush()
180
+ },
181
+
182
+ enqueueUpdate(annotation: AnnotationV2) {
183
+ if (sync.autoSync === false) return
184
+ void updateRemote(annotation)
185
+ },
186
+
187
+ enqueueDelete(annotation: AnnotationV2) {
188
+ if (sync.autoSync === false) return
189
+ void deleteRemote(annotation)
190
+ },
191
+ }
192
+ }