@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.
- package/package.json +89 -5
- package/src/agents/mcpServerPresets.test.ts +105 -0
- package/src/agents/mcpServerPresets.ts +380 -0
- package/src/agents/runtime/index.ts +7 -0
- package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
- package/src/agents/runtime/pendingAsks.test.ts +117 -0
- package/src/agents/runtime/pendingAsks.ts +110 -0
- package/src/bridge/agentScopeDefaults.ts +65 -0
- package/src/bridge/agentTypes.ts +57 -3
- package/src/bridge/agents.d.mts +6 -0
- package/src/bridge/agents.mjs +10 -0
- package/src/bridge/agents.ts +4 -0
- package/src/bridge/collectionImagePaste.ts +19 -1
- package/src/bridge/context.mjs +32 -0
- package/src/bridge/credentials.d.mts +62 -0
- package/src/bridge/credentials.mjs +53 -0
- package/src/bridge/documents.d.mts +76 -0
- package/src/bridge/documents.mjs +40 -0
- package/src/bridge/events.d.mts +1 -0
- package/src/bridge/events.mjs +1 -0
- package/src/bridge/googleAuth.ts +7 -152
- package/src/bridge/mcpClient.d.mts +74 -0
- package/src/bridge/mcpClient.mjs +79 -0
- package/src/bridge/methods.d.mts +45 -0
- package/src/bridge/methods.mjs +46 -0
- package/src/bridge/notifications.d.mts +12 -0
- package/src/bridge/notifications.mjs +36 -0
- package/src/bridge/operations.d.mts +44 -0
- package/src/bridge/operations.mjs +40 -0
- package/src/bridge/pureRender/base.css +44 -0
- package/src/bridge/pureRender/document.ts +29 -2
- package/src/bridge/pureRender/index.ts +3 -0
- package/src/bridge/pureRender/normalize.ts +167 -0
- package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
- package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
- package/src/bridge/pureRender/styles.ts +125 -5
- package/src/bridge/pureRender/theme.test.ts +259 -0
- package/src/bridge/pureRender/theme.ts +19 -1
- package/src/bridge/pureRender/types.ts +33 -0
- package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
- package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
- package/src/bridge/react/useDocumentHotkeys.ts +41 -0
- package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
- package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
- package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
- package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
- package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
- package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
- package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
- package/src/bridge/react/usePlatformOperations.tsx +75 -0
- package/src/bridge/storage.d.mts +23 -10
- package/src/bridge/storage.mjs +6 -3
- package/src/bridge/storage.test.ts +6 -6
- package/src/bridge/types.ts +7 -0
- package/src/components/agents/AgentComposer.tsx +10 -6
- package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
- package/src/components/agents/AgentDrawerPanel.tsx +23 -62
- package/src/components/agents/AgentMessageBubble.tsx +59 -33
- package/src/components/agents/AgentMessageList.tsx +4 -4
- package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
- package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
- package/src/components/agents/AgentToolCallCard.tsx +20 -20
- package/src/components/agents/AgentToolPendingList.tsx +6 -6
- package/src/components/agents/AgentToolPendingReview.tsx +6 -6
- package/src/components/agents/ContextPicker.tsx +239 -0
- package/src/components/agents/QuestionRequestList.tsx +278 -0
- package/src/components/agents/agentPanelStyles.ts +8 -31
- package/src/components/agents/agentTypes.ts +25 -41
- package/src/components/agents/index.ts +4 -0
- package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
- package/src/components/chrome/WorkspaceTabStrip.tsx +22 -4
- package/src/components/chrome/workspaceTabTypes.ts +2 -0
- package/src/components/common/chat/ChatBox.tsx +9 -9
- package/src/components/common/chat/ChatModelMenu.tsx +34 -4
- package/src/components/common/chat/ChatThread.tsx +23 -7
- package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
- package/src/components/common/connections/ProviderConnection.tsx +277 -0
- package/src/components/common/connections/index.ts +5 -0
- package/src/components/common/containers/AppFrame.test.tsx +4 -4
- package/src/components/common/containers/AppFrame.tsx +26 -10
- package/src/components/common/containers/AppHeader.tsx +37 -15
- package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
- package/src/components/common/documents/DocumentSwitcher.tsx +1181 -0
- package/src/components/common/documents/index.ts +8 -0
- package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
- package/src/components/common/dropdown/MenuPortal.tsx +3 -1
- package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
- package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
- package/src/components/common/overlays/Modal.tsx +13 -2
- package/src/components/common/research/EvidenceDossier.tsx +1232 -150
- package/src/components/common/research/index.ts +2 -0
- package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
- package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
- package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
- package/src/components/print-design/index.ts +2 -0
- package/src/components/print-design/previewContent.test.ts +32 -0
- package/src/components/print-design/previewContent.ts +31 -0
- package/src/context/buildContextDocument.test.ts +240 -0
- package/src/context/buildContextDocument.ts +309 -0
- package/src/context/contextAccess.ts +66 -0
- package/src/context/contextConfig.ts +72 -0
- package/src/context/contextDocument.test.ts +155 -0
- package/src/context/contextDocument.ts +300 -0
- package/src/context/contextSections.test.ts +88 -0
- package/src/context/contextSections.ts +84 -0
- package/src/context/htmlToContextMarkdown.test.ts +134 -0
- package/src/context/htmlToContextMarkdown.ts +369 -0
- package/src/ics/generateIcs.test.ts +153 -0
- package/src/ics/generateIcs.ts +197 -0
- package/src/index.ts +3 -0
- package/src/net/httpRetry.test.ts +117 -0
- package/src/net/httpRetry.ts +111 -0
- package/src/theme/appAccents.test.ts +36 -35
- package/src/theme/appAccents.ts +93 -104
- package/src/theme/appIdentityCss.mjs +178 -229
- package/src/theme/appIdentityCss.ts +222 -231
- package/src/theme/themes/dark.ts +3 -1
- package/src/theme/themes/light.ts +3 -1
- package/src/bridge/googleProviderConfig.ts +0 -73
|
@@ -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
|
+
}
|