@puredesktop/puredesktop-ui-bridge 2.1.7 → 2.1.9

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 (119) hide show
  1. package/package.json +89 -5
  2. package/src/agents/mcpServerPresets.test.ts +105 -0
  3. package/src/agents/mcpServerPresets.ts +380 -0
  4. package/src/agents/runtime/index.ts +7 -0
  5. package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
  6. package/src/agents/runtime/pendingAsks.test.ts +117 -0
  7. package/src/agents/runtime/pendingAsks.ts +110 -0
  8. package/src/bridge/agentScopeDefaults.ts +65 -0
  9. package/src/bridge/agentTypes.ts +57 -3
  10. package/src/bridge/agents.d.mts +6 -0
  11. package/src/bridge/agents.mjs +10 -0
  12. package/src/bridge/agents.ts +4 -0
  13. package/src/bridge/collectionImagePaste.ts +19 -1
  14. package/src/bridge/context.mjs +32 -0
  15. package/src/bridge/credentials.d.mts +62 -0
  16. package/src/bridge/credentials.mjs +53 -0
  17. package/src/bridge/documents.d.mts +76 -0
  18. package/src/bridge/documents.mjs +40 -0
  19. package/src/bridge/events.d.mts +1 -0
  20. package/src/bridge/events.mjs +1 -0
  21. package/src/bridge/googleAuth.ts +7 -152
  22. package/src/bridge/mcpClient.d.mts +74 -0
  23. package/src/bridge/mcpClient.mjs +79 -0
  24. package/src/bridge/methods.d.mts +45 -0
  25. package/src/bridge/methods.mjs +46 -0
  26. package/src/bridge/notifications.d.mts +12 -0
  27. package/src/bridge/notifications.mjs +36 -0
  28. package/src/bridge/operations.d.mts +44 -0
  29. package/src/bridge/operations.mjs +40 -0
  30. package/src/bridge/pureRender/base.css +44 -0
  31. package/src/bridge/pureRender/document.ts +29 -2
  32. package/src/bridge/pureRender/index.ts +3 -0
  33. package/src/bridge/pureRender/normalize.ts +167 -0
  34. package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
  35. package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
  36. package/src/bridge/pureRender/styles.ts +125 -5
  37. package/src/bridge/pureRender/theme.test.ts +259 -0
  38. package/src/bridge/pureRender/theme.ts +19 -1
  39. package/src/bridge/pureRender/types.ts +33 -0
  40. package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
  41. package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
  42. package/src/bridge/react/useDocumentHotkeys.ts +41 -0
  43. package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
  44. package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
  45. package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
  46. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
  47. package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
  48. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
  49. package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
  50. package/src/bridge/react/usePlatformOperations.tsx +75 -0
  51. package/src/bridge/storage.d.mts +23 -10
  52. package/src/bridge/storage.mjs +6 -3
  53. package/src/bridge/storage.test.ts +6 -6
  54. package/src/bridge/types.ts +7 -0
  55. package/src/components/agents/AgentComposer.tsx +10 -6
  56. package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
  57. package/src/components/agents/AgentDrawerPanel.tsx +23 -62
  58. package/src/components/agents/AgentMessageBubble.tsx +59 -33
  59. package/src/components/agents/AgentMessageList.tsx +4 -4
  60. package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
  61. package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
  62. package/src/components/agents/AgentToolCallCard.tsx +20 -20
  63. package/src/components/agents/AgentToolPendingList.tsx +6 -6
  64. package/src/components/agents/AgentToolPendingReview.tsx +6 -6
  65. package/src/components/agents/ContextPicker.tsx +239 -0
  66. package/src/components/agents/QuestionRequestList.tsx +278 -0
  67. package/src/components/agents/agentPanelStyles.ts +8 -31
  68. package/src/components/agents/agentTypes.ts +25 -41
  69. package/src/components/agents/index.ts +4 -0
  70. package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
  71. package/src/components/chrome/WorkspaceTabStrip.tsx +22 -4
  72. package/src/components/chrome/workspaceTabTypes.ts +2 -0
  73. package/src/components/common/chat/ChatBox.tsx +9 -9
  74. package/src/components/common/chat/ChatModelMenu.tsx +34 -4
  75. package/src/components/common/chat/ChatThread.tsx +23 -7
  76. package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
  77. package/src/components/common/connections/ProviderConnection.tsx +277 -0
  78. package/src/components/common/connections/index.ts +5 -0
  79. package/src/components/common/containers/AppFrame.test.tsx +4 -4
  80. package/src/components/common/containers/AppFrame.tsx +26 -10
  81. package/src/components/common/containers/AppHeader.tsx +37 -15
  82. package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
  83. package/src/components/common/documents/DocumentSwitcher.tsx +1181 -0
  84. package/src/components/common/documents/index.ts +8 -0
  85. package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
  86. package/src/components/common/dropdown/MenuPortal.tsx +3 -1
  87. package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
  88. package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
  89. package/src/components/common/overlays/Modal.tsx +13 -2
  90. package/src/components/common/research/EvidenceDossier.tsx +1232 -150
  91. package/src/components/common/research/index.ts +2 -0
  92. package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
  93. package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
  94. package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
  95. package/src/components/print-design/index.ts +2 -0
  96. package/src/components/print-design/previewContent.test.ts +32 -0
  97. package/src/components/print-design/previewContent.ts +31 -0
  98. package/src/context/buildContextDocument.test.ts +240 -0
  99. package/src/context/buildContextDocument.ts +309 -0
  100. package/src/context/contextAccess.ts +66 -0
  101. package/src/context/contextConfig.ts +72 -0
  102. package/src/context/contextDocument.test.ts +155 -0
  103. package/src/context/contextDocument.ts +300 -0
  104. package/src/context/contextSections.test.ts +88 -0
  105. package/src/context/contextSections.ts +84 -0
  106. package/src/context/htmlToContextMarkdown.test.ts +134 -0
  107. package/src/context/htmlToContextMarkdown.ts +369 -0
  108. package/src/ics/generateIcs.test.ts +153 -0
  109. package/src/ics/generateIcs.ts +197 -0
  110. package/src/index.ts +3 -0
  111. package/src/net/httpRetry.test.ts +117 -0
  112. package/src/net/httpRetry.ts +111 -0
  113. package/src/theme/appAccents.test.ts +36 -35
  114. package/src/theme/appAccents.ts +93 -104
  115. package/src/theme/appIdentityCss.mjs +178 -229
  116. package/src/theme/appIdentityCss.ts +222 -231
  117. package/src/theme/themes/dark.ts +3 -1
  118. package/src/theme/themes/light.ts +3 -1
  119. package/src/bridge/googleProviderConfig.ts +0 -73
@@ -6,4 +6,6 @@ export {
6
6
  type PrintDesignPanelProps,
7
7
  type PrintDesignThemePreset,
8
8
  type PrintDesignPopoverProps,
9
+ type PrintDesignPreviewDocument,
9
10
  } from './PrintDesignPanel.js'
11
+ export { printPreviewDocumentFromHtml } from './previewContent.js'
@@ -0,0 +1,32 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { printPreviewDocumentFromHtml } from './previewContent.js'
4
+
5
+ describe('printPreviewDocumentFromHtml', () => {
6
+ it('extracts the heading and running paragraphs, skipping stubs', () => {
7
+ const result = printPreviewDocumentFromHtml(
8
+ '<h1>Designing for Emergent Workflow Cultures</h1>' +
9
+ '<p>Short.</p>' +
10
+ '<p>Scholarly publishing is evolving, and there is a need to understand and design the new emergent workflows.</p>' +
11
+ '<p>This article documents an ongoing collaboration to develop technology to meet emergent workflows in scholarly publishing.</p>',
12
+ )
13
+ expect(result.title).toBe('Designing for Emergent Workflow Cultures')
14
+ expect(result.paragraphs).toHaveLength(2)
15
+ expect(result.paragraphs?.[0]).toContain('Scholarly publishing is evolving')
16
+ })
17
+
18
+ it('prefers a supplied title/byline and caps paragraphs at four', () => {
19
+ const paragraphs = Array.from(
20
+ { length: 6 },
21
+ (_, index) =>
22
+ `<p>Paragraph number ${index} padded well past the forty character minimum length.</p>`,
23
+ ).join('')
24
+ const result = printPreviewDocumentFromHtml(`<h1>Ignored</h1>${paragraphs}`, {
25
+ title: 'Chosen Title',
26
+ byline: 'A. Author',
27
+ })
28
+ expect(result.title).toBe('Chosen Title')
29
+ expect(result.byline).toBe('A. Author')
30
+ expect(result.paragraphs).toHaveLength(4)
31
+ })
32
+ })
@@ -0,0 +1,31 @@
1
+ import type { PrintDesignPreviewDocument } from './PrintDesignPanel.js'
2
+
3
+ /**
4
+ * Derive the live-preview excerpt from a host document's HTML: the first
5
+ * heading as title (unless the host supplies one) and the first few real
6
+ * paragraphs. Keeps the PDF Typesetter preview showing the USER'S content
7
+ * instead of sample copy.
8
+ */
9
+ export function printPreviewDocumentFromHtml(
10
+ html: string,
11
+ options: { title?: string; byline?: string } = {},
12
+ ): PrintDesignPreviewDocument {
13
+ const doc = new DOMParser().parseFromString(html || '', 'text/html')
14
+ const title =
15
+ options.title?.trim() ||
16
+ doc.body.querySelector('h1, h2')?.textContent?.replace(/\s+/g, ' ').trim() ||
17
+ undefined
18
+ const paragraphs: string[] = []
19
+ for (const paragraph of Array.from(doc.body.querySelectorAll('p'))) {
20
+ const text = paragraph.textContent?.replace(/\s+/g, ' ').trim() ?? ''
21
+ // Skip stubs/captions; the preview wants running text.
22
+ if (text.length < 40) continue
23
+ paragraphs.push(text)
24
+ if (paragraphs.length >= 4) break
25
+ }
26
+ return {
27
+ ...(title ? { title } : {}),
28
+ ...(options.byline?.trim() ? { byline: options.byline.trim() } : {}),
29
+ paragraphs,
30
+ }
31
+ }
@@ -0,0 +1,240 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ buildContextDocument,
5
+ metadataMarkdown,
6
+ type PlatformContextBuildIo,
7
+ } from './buildContextDocument.js'
8
+ import { parseContextDocument } from './contextDocument.js'
9
+ import type { PlatformAppContextManifest } from './contextConfig.js'
10
+
11
+ function fakeIo(files: Record<string, string>, dirs: Record<string, string[]> = {}): PlatformContextBuildIo {
12
+ return {
13
+ async readTextFile(path) {
14
+ const content = files[path]
15
+ if (content === undefined) throw new Error(`ENOENT: ${path}`)
16
+ return content
17
+ },
18
+ async listFiles(dirPath) {
19
+ return dirs[dirPath] ?? []
20
+ },
21
+ }
22
+ }
23
+
24
+ const MANUSCRIPT_CONFIG: PlatformAppContextManifest = {
25
+ kind: 'manuscript',
26
+ packageSuffix: '.manuscript',
27
+ manifestFile: 'manuscript.json',
28
+ content: [{ file: 'manuscript.html', section: 'Body' }],
29
+ assetsDir: 'assets',
30
+ titlePath: 'title',
31
+ }
32
+
33
+ const BOOK_CONFIG: PlatformAppContextManifest = {
34
+ kind: 'book',
35
+ packageSuffix: '.book',
36
+ manifestFile: 'book.yaml',
37
+ content: [
38
+ { fromManifestList: 'frontMatter', baseDir: 'chapters' },
39
+ { fromManifestList: 'chapters', baseDir: 'chapters' },
40
+ ],
41
+ assetsDir: 'assets',
42
+ titlePath: 'title',
43
+ idPath: 'id',
44
+ }
45
+
46
+ describe('buildContextDocument (manuscript-shaped package)', () => {
47
+ const packagePath = '/work/MyPaper.manuscript'
48
+ const io = fakeIo(
49
+ {
50
+ '/work/MyPaper.manuscript/manuscript.json': JSON.stringify({
51
+ title: 'Effects of X on Y',
52
+ authors: [{ name: 'A. Author' }],
53
+ abstract: 'Short abstract.',
54
+ keywords: ['x', 'y'],
55
+ }),
56
+ '/work/MyPaper.manuscript/manuscript.html':
57
+ '<h2>Introduction</h2><p>Hello <strong>world</strong>.</p>',
58
+ },
59
+ {
60
+ '/work/MyPaper.manuscript/assets': ['fig-01.png', 'diagrams/flow.svg'],
61
+ },
62
+ )
63
+
64
+ it('produces a parseable document with full content and metadata', async () => {
65
+ const result = await buildContextDocument(
66
+ {
67
+ appSlug: 'manuscript',
68
+ packagePath,
69
+ config: MANUSCRIPT_CONFIG,
70
+ generator: 'on-access',
71
+ generatedAt: '2026-07-02T10:00:00.000Z',
72
+ sourceHash: 'sha256:abc',
73
+ },
74
+ io,
75
+ )
76
+ expect(result.fileName).toBe('MyPaper.manuscript.md')
77
+ const parsed = parseContextDocument(result.raw)
78
+ expect(parsed.envelope.title).toBe('Effects of X on Y')
79
+ expect(parsed.envelope.appSlug).toBe('manuscript')
80
+ expect(parsed.envelope.objectId).toBe('MyPaper')
81
+ expect(parsed.envelope.source.contentFiles).toEqual(['manuscript.html'])
82
+ expect(parsed.body).toContain('# Effects of X on Y')
83
+ expect(parsed.body).toContain('- **abstract:** Short abstract.')
84
+ expect(parsed.body).toContain('## Body')
85
+ expect(parsed.body).toContain('Hello **world**.')
86
+ // Content headings nest inside the section wrapper (h2 -> ###).
87
+ expect(parsed.body).toContain('### Introduction')
88
+ expect(parsed.body).not.toContain('\n## Introduction')
89
+ })
90
+
91
+ it('links assets with durable relative paths and kinds', async () => {
92
+ const result = await buildContextDocument(
93
+ {
94
+ appSlug: 'manuscript',
95
+ packagePath,
96
+ config: MANUSCRIPT_CONFIG,
97
+ generator: 'on-access',
98
+ generatedAt: '2026-07-02T10:00:00.000Z',
99
+ },
100
+ io,
101
+ )
102
+ expect(result.document.envelope.links).toEqual([
103
+ { relativePath: 'assets/diagrams/flow.svg', kind: 'vector' },
104
+ { relativePath: 'assets/fig-01.png', kind: 'image' },
105
+ ])
106
+ expect(result.document.body).toContain(
107
+ '- [fig-01.png](assets/fig-01.png) (image)',
108
+ )
109
+ })
110
+
111
+ it('is deterministic for a fixed timestamp', async () => {
112
+ const request = {
113
+ appSlug: 'manuscript',
114
+ packagePath,
115
+ config: MANUSCRIPT_CONFIG,
116
+ generator: 'on-access' as const,
117
+ generatedAt: '2026-07-02T10:00:00.000Z',
118
+ }
119
+ const first = await buildContextDocument(request, io)
120
+ const second = await buildContextDocument(request, io)
121
+ expect(first.raw).toBe(second.raw)
122
+ })
123
+
124
+ it('indexes sections including per-chapter headings', async () => {
125
+ const result = await buildContextDocument(
126
+ {
127
+ appSlug: 'manuscript',
128
+ packagePath,
129
+ config: MANUSCRIPT_CONFIG,
130
+ generator: 'on-access',
131
+ generatedAt: '2026-07-02T10:00:00.000Z',
132
+ },
133
+ io,
134
+ )
135
+ const ids = result.document.envelope.sections.map(section => section.id)
136
+ expect(ids).toContain('metadata')
137
+ expect(ids).toContain('body')
138
+ expect(ids).toContain('assets')
139
+ })
140
+
141
+ it('marks missing content files instead of failing', async () => {
142
+ const brokenIo = fakeIo({
143
+ '/work/MyPaper.manuscript/manuscript.json': JSON.stringify({ title: 'T' }),
144
+ })
145
+ const result = await buildContextDocument(
146
+ {
147
+ appSlug: 'manuscript',
148
+ packagePath,
149
+ config: MANUSCRIPT_CONFIG,
150
+ generator: 'on-access',
151
+ generatedAt: '2026-07-02T10:00:00.000Z',
152
+ },
153
+ brokenIo,
154
+ )
155
+ expect(result.document.body).toContain(
156
+ '*Missing content file: manuscript.html*',
157
+ )
158
+ })
159
+
160
+ it('throws when the package manifest is unreadable', async () => {
161
+ await expect(
162
+ buildContextDocument(
163
+ {
164
+ appSlug: 'manuscript',
165
+ packagePath: '/work/Nope.manuscript',
166
+ config: MANUSCRIPT_CONFIG,
167
+ generator: 'on-access',
168
+ generatedAt: '2026-07-02T10:00:00.000Z',
169
+ },
170
+ io,
171
+ ),
172
+ ).rejects.toThrow(/ENOENT/)
173
+ })
174
+ })
175
+
176
+ describe('buildContextDocument (book-shaped package with manifest lists)', () => {
177
+ const packagePath = '/work/MyBook.book'
178
+ const io = fakeIo(
179
+ {
180
+ '/work/MyBook.book/book.yaml': [
181
+ 'schema: 1',
182
+ 'id: book-42',
183
+ 'title: My Book',
184
+ 'author: A. Writer',
185
+ 'frontMatter:',
186
+ ' - file: preface.html',
187
+ ' title: Preface',
188
+ 'chapters:',
189
+ ' - file: ch1.html',
190
+ ' title: Chapter One',
191
+ ' - file: ch2.html',
192
+ 'render:',
193
+ ' pageSize: A5',
194
+ ' margins: normal',
195
+ ].join('\n'),
196
+ '/work/MyBook.book/chapters/preface.html': '<p>Preface text.</p>',
197
+ '/work/MyBook.book/chapters/ch1.html': '<p>One.</p>',
198
+ '/work/MyBook.book/chapters/ch2.html': '<p>Two.</p>',
199
+ },
200
+ )
201
+
202
+ it('orders chapters from the manifest lists and titles sections', async () => {
203
+ const result = await buildContextDocument(
204
+ {
205
+ appSlug: 'book',
206
+ packagePath,
207
+ config: BOOK_CONFIG,
208
+ generator: 'on-access',
209
+ generatedAt: '2026-07-02T10:00:00.000Z',
210
+ },
211
+ io,
212
+ )
213
+ expect(result.contentFiles).toEqual([
214
+ 'chapters/preface.html',
215
+ 'chapters/ch1.html',
216
+ 'chapters/ch2.html',
217
+ ])
218
+ expect(result.document.envelope.objectId).toBe('book-42')
219
+ const body = result.document.body
220
+ expect(body.indexOf('## Preface')).toBeGreaterThan(-1)
221
+ expect(body.indexOf('## Chapter One')).toBeGreaterThan(
222
+ body.indexOf('## Preface'),
223
+ )
224
+ expect(body).toContain('## ch2')
225
+ })
226
+ })
227
+
228
+ describe('metadataMarkdown', () => {
229
+ it('renders nested records and arrays', () => {
230
+ const lines = metadataMarkdown({
231
+ title: 'T',
232
+ authors: [{ name: 'A' }],
233
+ keywords: ['x', 'y'],
234
+ })
235
+ expect(lines).toContain('- **title:** T')
236
+ expect(lines).toContain('- **authors:**')
237
+ expect(lines).toContain(' - **name:** A')
238
+ expect(lines).toContain(' - x')
239
+ })
240
+ })
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Generic, config-driven context document builder.
3
+ *
4
+ * One implementation with injectable IO so the shell can generate headless
5
+ * (node fs) and apps can generate on save (bridge fs) with byte-identical
6
+ * output. App schema comes declaratively from the `app.context` manifest
7
+ * block — no app-specific logic lives here or in the shell.
8
+ */
9
+
10
+ import { parse as parseYaml } from 'yaml'
11
+
12
+ import {
13
+ serializeContextDocument,
14
+ type PlatformContextDocument,
15
+ type PlatformContextGenerator,
16
+ type PlatformContextLink,
17
+ PLATFORM_CONTEXT_SCHEMA_VERSION,
18
+ contextFileName,
19
+ } from './contextDocument.js'
20
+ import { buildContextSections } from './contextSections.js'
21
+ import {
22
+ contextLinkKindForFile,
23
+ manifestStringAt,
24
+ type PlatformAppContextManifest,
25
+ } from './contextConfig.js'
26
+ import { htmlToContextMarkdown } from './htmlToContextMarkdown.js'
27
+
28
+ /** IO surface the builder needs — node fs in the shell, bridge fs in apps. */
29
+ export interface PlatformContextBuildIo {
30
+ readTextFile(path: string): Promise<string>
31
+ /** Recursive relative file listing of a directory; [] when missing. */
32
+ listFiles(dirPath: string): Promise<string[]>
33
+ }
34
+
35
+ export interface PlatformContextBuildRequest {
36
+ appSlug: string
37
+ /** Absolute path of the package folder (the assets collectionPath). */
38
+ packagePath: string
39
+ config: PlatformAppContextManifest
40
+ generator: PlatformContextGenerator
41
+ /** Injected ISO timestamp — callers stamp so output stays deterministic. */
42
+ generatedAt: string
43
+ /** Hash of manifest + content files, computed by the caller. */
44
+ sourceHash?: string
45
+ }
46
+
47
+ export interface PlatformContextBuildResult {
48
+ document: PlatformContextDocument
49
+ /** Serialized on-disk form. */
50
+ raw: string
51
+ /** Canonical context file name, `<packageBase>.<kind>.md`. */
52
+ fileName: string
53
+ /** Content file paths (relative to the package) the body derived from. */
54
+ contentFiles: string[]
55
+ }
56
+
57
+ interface ResolvedContentEntry {
58
+ relativePath: string
59
+ sectionTitle: string
60
+ }
61
+
62
+ function baseName(path: string): string {
63
+ const trimmed = path.replace(/[\\/]+$/, '')
64
+ const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
65
+ return separator >= 0 ? trimmed.slice(separator + 1) : trimmed
66
+ }
67
+
68
+ function packageBaseName(packagePath: string, packageSuffix: string): string {
69
+ const name = baseName(packagePath)
70
+ return name.endsWith(packageSuffix)
71
+ ? name.slice(0, -packageSuffix.length)
72
+ : name
73
+ }
74
+
75
+ function joinPath(...parts: string[]): string {
76
+ return parts
77
+ .filter(part => part.length > 0)
78
+ .join('/')
79
+ .replace(/\/{2,}/g, '/')
80
+ }
81
+
82
+ function parseManifestText(fileName: string, text: string): unknown {
83
+ if (/\.ya?ml$/i.test(fileName)) return parseYaml(text)
84
+ return JSON.parse(text)
85
+ }
86
+
87
+ function titleForFile(relativePath: string): string {
88
+ const name = baseName(relativePath)
89
+ return name.replace(/\.[^.]+$/, '').replace(/\.(doc|report|research)$/, '')
90
+ }
91
+
92
+ /** Render parsed manifest data as a readable markdown list. */
93
+ export function metadataMarkdown(value: unknown, depth = 0): string[] {
94
+ const indent = ' '.repeat(depth)
95
+ if (Array.isArray(value)) {
96
+ return value.flatMap(entry => {
97
+ if (entry !== null && typeof entry === 'object') {
98
+ const nested = metadataMarkdown(entry, depth + 1)
99
+ return [`${indent}-`, ...nested]
100
+ }
101
+ return [`${indent}- ${formatScalar(entry)}`]
102
+ })
103
+ }
104
+ if (value !== null && typeof value === 'object') {
105
+ return Object.entries(value as Record<string, unknown>).flatMap(
106
+ ([key, entry]) => {
107
+ if (entry === null || entry === undefined) return []
108
+ if (typeof entry === 'object') {
109
+ const nested = metadataMarkdown(entry, depth + 1)
110
+ if (nested.length === 0) return []
111
+ return [`${indent}- **${key}:**`, ...nested]
112
+ }
113
+ return [`${indent}- **${key}:** ${formatScalar(entry)}`]
114
+ },
115
+ )
116
+ }
117
+ return [`${indent}- ${formatScalar(value)}`]
118
+ }
119
+
120
+ function formatScalar(value: unknown): string {
121
+ if (typeof value === 'string') return value.replace(/\s+/g, ' ').trim()
122
+ return String(value)
123
+ }
124
+
125
+ function resolveContentEntries(
126
+ config: PlatformAppContextManifest,
127
+ manifest: unknown,
128
+ ): ResolvedContentEntry[] {
129
+ const entries: ResolvedContentEntry[] = []
130
+ for (const source of config.content) {
131
+ if (source.file) {
132
+ entries.push({
133
+ relativePath: source.file,
134
+ sectionTitle: source.section ?? titleForFile(source.file),
135
+ })
136
+ continue
137
+ }
138
+ if (!source.fromManifestList) continue
139
+ const listValue =
140
+ manifest && typeof manifest === 'object'
141
+ ? (manifest as Record<string, unknown>)[source.fromManifestList]
142
+ : undefined
143
+ if (!Array.isArray(listValue)) continue
144
+ const pathKey = source.pathKey ?? 'file'
145
+ const titleKey = source.titleKey ?? 'title'
146
+ for (const item of listValue) {
147
+ if (item === null || typeof item !== 'object') continue
148
+ const record = item as Record<string, unknown>
149
+ const file = record[pathKey]
150
+ if (typeof file !== 'string' || file.trim().length === 0) continue
151
+ const relativePath = source.baseDir
152
+ ? joinPath(source.baseDir, file.trim())
153
+ : file.trim()
154
+ const title = record[titleKey]
155
+ entries.push({
156
+ relativePath,
157
+ sectionTitle:
158
+ typeof title === 'string' && title.trim().length > 0
159
+ ? title.trim()
160
+ : titleForFile(relativePath),
161
+ })
162
+ }
163
+ }
164
+ return entries
165
+ }
166
+
167
+ /**
168
+ * Demote markdown headings so converted content nests *inside* its `##`
169
+ * section wrapper (minimum content heading level becomes 3). Without this a
170
+ * content `<h2>` would terminate the wrapper section and break segmented
171
+ * reads. Fenced code blocks are left untouched.
172
+ */
173
+ export function nestContentHeadings(markdown: string): string {
174
+ const lines = markdown.split('\n')
175
+ let fence: string | null = null
176
+ let minLevel = Number.POSITIVE_INFINITY
177
+ const levels: (number | null)[] = lines.map(line => {
178
+ const fenceMatch = /^(```|~~~)/.exec(line.trimStart())
179
+ if (fenceMatch) {
180
+ if (fence === null) fence = fenceMatch[1]
181
+ else if (line.trimStart().startsWith(fence)) fence = null
182
+ return null
183
+ }
184
+ if (fence !== null) return null
185
+ const match = /^(#{1,6})\s/.exec(line)
186
+ if (!match) return null
187
+ minLevel = Math.min(minLevel, match[1].length)
188
+ return match[1].length
189
+ })
190
+ if (!Number.isFinite(minLevel) || minLevel >= 3) return markdown
191
+ const shift = 3 - minLevel
192
+ return lines
193
+ .map((line, index) => {
194
+ const level = levels[index]
195
+ if (level === null) return line
196
+ const next = Math.min(6, level + shift)
197
+ return `${'#'.repeat(next)}${line.slice(level)}`
198
+ })
199
+ .join('\n')
200
+ }
201
+
202
+ async function contentSectionMarkdown(
203
+ entry: ResolvedContentEntry,
204
+ packagePath: string,
205
+ io: PlatformContextBuildIo,
206
+ ): Promise<string[]> {
207
+ let text: string
208
+ try {
209
+ text = await io.readTextFile(joinPath(packagePath, entry.relativePath))
210
+ } catch {
211
+ return [`## ${entry.sectionTitle}`, '', `*Missing content file: ${entry.relativePath}*`]
212
+ }
213
+ const lower = entry.relativePath.toLowerCase()
214
+ if (lower.endsWith('.html') || lower.endsWith('.htm')) {
215
+ return [
216
+ `## ${entry.sectionTitle}`,
217
+ '',
218
+ nestContentHeadings(htmlToContextMarkdown(text).trimEnd()),
219
+ ]
220
+ }
221
+ if (lower.endsWith('.md') || lower.endsWith('.markdown') || lower.endsWith('.txt')) {
222
+ return [`## ${entry.sectionTitle}`, '', nestContentHeadings(text.trim())]
223
+ }
224
+ const fence = lower.endsWith('.json')
225
+ ? 'json'
226
+ : /\.ya?ml$/.test(lower)
227
+ ? 'yaml'
228
+ : ''
229
+ return [`## ${entry.sectionTitle}`, '', `\`\`\`${fence}`, text.trim(), '```']
230
+ }
231
+
232
+ /**
233
+ * Build the full context document for one package object.
234
+ * Throws when the package manifest cannot be read or parsed.
235
+ */
236
+ export async function buildContextDocument(
237
+ request: PlatformContextBuildRequest,
238
+ io: PlatformContextBuildIo,
239
+ ): Promise<PlatformContextBuildResult> {
240
+ const { config, packagePath } = request
241
+ const manifestText = await io.readTextFile(
242
+ joinPath(packagePath, config.manifestFile),
243
+ )
244
+ const manifest = parseManifestText(config.manifestFile, manifestText)
245
+ const base = packageBaseName(packagePath, config.packageSuffix)
246
+ const title = manifestStringAt(manifest, config.titlePath ?? 'title') ?? base
247
+ const objectId = manifestStringAt(manifest, config.idPath) ?? base
248
+
249
+ const contentEntries = resolveContentEntries(config, manifest)
250
+ const bodyParts: string[] = [`# ${title}`, '']
251
+
252
+ const metadataLines = metadataMarkdown(manifest)
253
+ if (metadataLines.length > 0) {
254
+ bodyParts.push('## Metadata', '', ...metadataLines, '')
255
+ }
256
+
257
+ for (const entry of contentEntries) {
258
+ bodyParts.push(...(await contentSectionMarkdown(entry, packagePath, io)), '')
259
+ }
260
+
261
+ const links: PlatformContextLink[] = []
262
+ if (config.assetsDir) {
263
+ const assetFiles = await io.listFiles(joinPath(packagePath, config.assetsDir))
264
+ for (const file of [...assetFiles].sort()) {
265
+ links.push({
266
+ relativePath: joinPath(config.assetsDir, file),
267
+ kind: contextLinkKindForFile(file),
268
+ })
269
+ }
270
+ }
271
+ if (links.length > 0) {
272
+ bodyParts.push(
273
+ '## Assets',
274
+ '',
275
+ ...links.map(
276
+ link => `- [${baseName(link.relativePath)}](${link.relativePath}) (${link.kind})`,
277
+ ),
278
+ '',
279
+ )
280
+ }
281
+
282
+ const body = `${bodyParts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
283
+ const document: PlatformContextDocument = {
284
+ envelope: {
285
+ schemaVersion: PLATFORM_CONTEXT_SCHEMA_VERSION,
286
+ kind: config.kind,
287
+ appSlug: request.appSlug,
288
+ objectId,
289
+ title,
290
+ source: {
291
+ path: packagePath,
292
+ manifestFile: config.manifestFile,
293
+ contentFiles: contentEntries.map(entry => entry.relativePath),
294
+ },
295
+ ...(request.sourceHash ? { sourceHash: request.sourceHash } : {}),
296
+ generatedAt: request.generatedAt,
297
+ generator: request.generator,
298
+ links,
299
+ sections: buildContextSections(body),
300
+ },
301
+ body,
302
+ }
303
+ return {
304
+ document,
305
+ raw: serializeContextDocument(document),
306
+ fileName: contextFileName(base, config.kind),
307
+ contentFiles: contentEntries.map(entry => entry.relativePath),
308
+ }
309
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Request/result contracts for the `context.*` bridge namespace
3
+ * (`context.resolve`, `context.read`, `context.search`).
4
+ *
5
+ * One backing implementation serves both the human context picker and the
6
+ * built-in agent tool — human-picked and agent-picked context are identical.
7
+ */
8
+
9
+ import type { PlatformContextEnvelope } from './contextDocument.js'
10
+
11
+ /**
12
+ * Reference to one context object. Provide `path` (any path on or inside the
13
+ * package folder) or `appSlug` + `objectId`.
14
+ */
15
+ export interface PlatformContextRef {
16
+ path?: string
17
+ appSlug?: string
18
+ objectId?: string
19
+ }
20
+
21
+ export interface PlatformContextResolveResult {
22
+ appSlug: string
23
+ kind: string
24
+ objectId: string
25
+ title: string
26
+ /** Absolute package folder path (the assets collectionPath). */
27
+ packagePath: string
28
+ /** Absolute path of the `<name>.<kind>.md` context file. */
29
+ contextPath: string
30
+ envelope: PlatformContextEnvelope
31
+ /** True when this call generated or refreshed the file. */
32
+ regenerated: boolean
33
+ }
34
+
35
+ export interface PlatformContextReadRequest extends PlatformContextRef {
36
+ /** Durable section id for a segmented read (see envelope.sections). */
37
+ section?: string
38
+ }
39
+
40
+ export interface PlatformContextReadResult extends PlatformContextResolveResult {
41
+ /** Full document markdown, or just the requested section's markdown. */
42
+ markdown: string
43
+ /** Echoes the section id when a segmented read was served. */
44
+ section?: string
45
+ }
46
+
47
+ export interface PlatformContextSearchRequest {
48
+ query: string
49
+ /** Restrict to object kinds, e.g. ['manuscript', 'book']. */
50
+ kinds?: string[]
51
+ /** Restrict to one app's objects. */
52
+ appSlug?: string
53
+ /** Max hits (default 12). */
54
+ limit?: number
55
+ }
56
+
57
+ export interface PlatformContextSearchHit {
58
+ appSlug: string
59
+ kind: string
60
+ title: string
61
+ packagePath: string
62
+ /** Canonical context file path (may not exist yet — resolve generates). */
63
+ contextPath: string
64
+ /** True when the context file already exists on disk. */
65
+ hasContext: boolean
66
+ }