@pmeweb/plugin-chat 1.2.1 → 1.3.0

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/INSTALL.md CHANGED
@@ -90,28 +90,61 @@ package's, approved once.
90
90
  `design.colors`. `opener` is `t.chat.openers[path]`, else
91
91
  the default filled with the page's title; `logo` only for `avatar: "logo"` (an `?url` import of
92
92
  the site's logo). The panel links to `privacyHref`.
93
- 8. **The context file**, `src/pages/plugins/chat/context.json.ts`:
93
+ 8. **The context file** (since 1.3.0: what the assistant knows). Three sources, nothing else:
94
+
95
+ - **`src/chat/context.<lang>.md`**, one per language of the site (manifest `context`), WRITTEN
96
+ BY CLAUDE CODE AT INSTALL for the assistant, from the site's pages and dictionary (« The
97
+ context file » below). Installing the chat is writing this file.
98
+ - **The pricing source** (`public/pricing.md`, or what the site's `pricing.md` route renders):
99
+ prices live there only, never in the `.md`.
100
+ - **The collections listed in `pmeweb.json` `plugins.chat.contextCollections`**
101
+ (`"portfolio"`, `"realisations,services"`: names separated by commas, no space): one line per
102
+ published entry, its title and a summary (its `summary`, else `resume`/`description`/`about`...,
103
+ else the start of its body, cut at 200 characters). Never `blog` or `authors` (the package and
104
+ the gate refuse them). The portal publishes these entries without Claude Code, so they stay
105
+ current by themselves. List a collection only when it answers a visitor's question (« avez-vous
106
+ fait un site pour un couvreur? »).
107
+
108
+ The route, `src/pages/plugins/chat/context.json.ts`:
94
109
 
95
110
  ```ts
96
- import { chatContext } from '@pmeweb/plugin-chat/build';
97
- import { fr } from '../../../i18n/fr';
98
- import { en } from '../../../i18n/en';
99
- import { llmsText } from '../../../lib/llms'; // what src/pages/llms.txt.ts renders
100
- import { pricingText } from '../../../lib/pricing'; // the source of public/pricing.md
111
+ import { getCollection } from 'astro:content';
112
+ import { chatContext, contextCollections } from '@pmeweb/plugin-chat/build';
113
+ import site from '../../../../pmeweb.json';
114
+ import pricing from '../../../../public/pricing.md?raw';
115
+ import contextFr from '../../../chat/context.fr.md?raw';
116
+ import contextEn from '../../../chat/context.en.md?raw';
101
117
  export const prerender = true;
102
- export const GET = async () => new Response(chatContext({
103
- langs: {
104
- fr: { business: 'Nom', llms: await llmsText('fr'), pricing: pricingText('fr'), dictionary: fr },
105
- en: { business: 'Name', llms: await llmsText('en'), pricing: pricingText('en'), dictionary: en },
106
- },
107
- collections: [/* published entries: { collection, lang?, title, data, body } */],
108
- }));
118
+ export const GET = async () => {
119
+ const entries = [];
120
+ for (const name of contextCollections(site.plugins?.chat?.contextCollections)) {
121
+ for (const e of getPublished(await getCollection(name))) { // the site's own filter
122
+ entries.push({ collection: name, label: 'Réalisations', lang: e.data.lang, title: e.data.titre, data: e.data, body: e.body });
123
+ }
124
+ }
125
+ return new Response(chatContext({
126
+ langs: { fr: { business: 'Nom', context: contextFr, pricing }, en: { business: 'Name', context: contextEn, pricing } },
127
+ collections: entries,
128
+ }));
129
+ };
109
130
  ```
110
131
 
111
- The `lib/` names differ per site: read its `src/pages/llms.txt.ts` and pricing module and import
112
- what they export (extract a function if the page inlines it). Never read `dist/` from a route.
113
- Collections through `getPublished` only. A French-only site passes `fr` alone. Above 200 KB the
114
- build fails.
132
+ `title` is the collection's `titleField`; `summary` may be composed by the site when no single
133
+ field says it (pmeweb: the portfolio's industry, then its « about »). A French-only site passes
134
+ `fr` alone. Never read `dist/` from a route. The build fails on a missing or empty `.md`, a `.md`
135
+ over 16 000 characters, a blog collection, or a file over 200 KB.
136
+
137
+ **The gate.** `a11y-tools/check-plugins.mjs` (pre-push and the builder's « Publier » preflight)
138
+ FAILS when a language's file is missing, over 16 000 characters, contains a price, names a blog
139
+ collection in `contextCollections`, or when its first line's fingerprint
140
+ (`<!-- chat-context sources:<16 hex> -->`) is not the hash of the site's current sources: its
141
+ dictionaries `src/i18n/*.ts`, its pricing source (`public/pricing.md`,
142
+ `src/{lib,data}/{prices,pricing}.*`) and its page files under `src/pages` (the blog routes,
143
+ `plugins/` and `og-card/` excluded). After writing or reviewing the files, from the site:
144
+ `node ../../a11y-tools/chat-context.mjs --stamp`. Renewing the stamp is a deliberate act after a
145
+ review, never a reflex to make the gate pass: read what changed, put in the file what is new
146
+ information for a visitor (a section, a service, a process, a policy), then stamp.
147
+
115
148
  9. **Privacy**: nothing to paste. `<PluginPrivacy>` renders the package's section (and the voip.ms
116
149
  and photos paragraphs when those keys are set), only while `enabled` is true: it needs
117
150
  `@pmeweb/consent` 1.1.0 or later (manifest `activeWhen`; 1.0.0 would print it switched off, and
@@ -127,3 +160,42 @@ package's, approved once.
127
160
  13. Deploy (still off: nothing on the site, health `disabled`). Once the client or the operator
128
161
  switches it on and publishes, from a real host: the bubble shows, a question gets an answer, the signed health
129
162
  route answers `chat.ok`.
163
+
164
+ ## The context file
165
+
166
+ Written for the assistant, not for a visitor; it is never published. Read every page of the site
167
+ (`src/pages`, the components they render, the dictionary of the language) and the legal pages,
168
+ then write, in the site's language and wording, answer-shaped plain facts under `## ` headings
169
+ (each heading becomes a section the model reads):
170
+
171
+ - the business: what it does, for whom, where, its languages; the legal name and city when a page
172
+ already says them;
173
+ - the offer: each service or product and what it includes, in the site's own words;
174
+ - the process and the timelines;
175
+ - the frequent questions the site answers (its FAQ blocks, its terms: payment, cancellation,
176
+ ownership), each as a fact;
177
+ - what the business does not do, only where the site says so (never deduced from silence);
178
+ - how to reach it: form address, email, phone, hours, response time.
179
+
180
+ Rules: nothing that is not already public on the site (workspace rule: never newly publish client
181
+ information); **no price** (the gate refuses `$` with a digit; write « voir Tarifs »); no blog
182
+ post; no marketing adjectives, no em-dash, OQLF typography in French (`docs/AVOIDING_AI_TELLS.md`);
183
+ 16 000 characters at most per language (pmeweb.ca: about 10 000). Then stamp it.
184
+
185
+ **Every later site change that adds or changes information** (a section, a service, a process, a
186
+ policy) updates the file in the same change; the gate says when the sources moved. Blog posts
187
+ never go in.
188
+
189
+ ## Updating from 1.2.1
190
+
191
+ 1.3.0 changes what the context is built from (1.2.1 sent llms.txt, the whole dictionary and every
192
+ entry's full text: about 26 000 tokens per reply on pmeweb.ca, mostly interface labels).
193
+ `context.json` keeps format 1; pmeweb-chat needs no change.
194
+
195
+ 1. Write `src/chat/context.<lang>.md` for each language (« The context file »), then
196
+ `node ../../a11y-tools/chat-context.mjs --stamp`.
197
+ 2. Rewrite `src/pages/plugins/chat/context.json.ts` as in step 8: `chatContext` no longer accepts
198
+ `llms` or `dictionary` (the build throws naming this section). Drop the blog posts.
199
+ 3. If a collection helps answer visitors, list it: `"contextCollections": "portfolio"` in
200
+ `pmeweb.json` `plugins.chat`.
201
+ 4. `npm run build && node ../../a11y-tools/check-plugins.mjs .` until PASS.
package/README.md CHANGED
@@ -17,6 +17,22 @@ Three pieces:
17
17
 
18
18
  Install it with the `install-plugin` skill (`INSTALL.md`).
19
19
 
20
+ ## What the assistant knows (1.3.0)
21
+
22
+ `plugins/chat/context.json` (format 1, unchanged) is built from three sources only:
23
+ `src/chat/context.<lang>.md`, a file Claude Code writes for the assistant from the site (the
24
+ business, the offer and what it includes, the process, the frequent questions, what the business
25
+ does not do, how to reach it; no price, nothing not already public), the site's pricing source,
26
+ and the collections listed in `pmeweb.json` `plugins.chat.contextCollections` as one line per
27
+ entry (title and a 200-character summary). Never the blog, the dictionary or llms.txt. On
28
+ pmeweb.ca that is about 4 500 input tokens per reply instead of 22 300 (measured 2026-11-13, 38 replies each).
29
+
30
+ The `.md` carries a fingerprint of the site's sources on its first line;
31
+ `a11y-tools/check-plugins.mjs` fails a push (and the builder's preflight) when the site changed
32
+ since the last review, until someone rereads the change, updates the file and runs
33
+ `node ../../a11y-tools/chat-context.mjs --stamp`. Details: `INSTALL.md`, step 8 and « The context
34
+ file ».
35
+
20
36
  ## Behaviour worth knowing
21
37
 
22
38
  - **A demo in the preview** (manifest `preview: "test"`). Off the site's real hosts
@@ -153,6 +169,9 @@ site changes with `a11y-tools/contrast.mjs`.
153
169
  says `settings: false`.
154
170
  - The context file absent from `dist/` or not served signed: the bot falls back to the contact form
155
171
  on every message.
172
+ - A fact the site states but `src/chat/context.<lang>.md` does not: the bot says it does not know.
173
+ The fingerprint catches changes to the dictionaries, the pricing source and `src/pages`, not to a
174
+ component that hardcodes text (a site's copy belongs in its dictionary).
156
175
 
157
176
  ## Avatars
158
177
 
package/manifest.json CHANGED
@@ -15,7 +15,8 @@
15
15
  "sms": { "pattern": "^voipms$", "optional": true },
16
16
  "photos": { "type": "boolean", "optional": true },
17
17
  "avatar": { "pattern": "^(?:photos-[1-5]|illustration-[1-5]|sans-visage-[1-5]|logo)$" },
18
- "avatarBg": { "pattern": "^#[0-9a-f]{6}$", "optional": true }
18
+ "avatarBg": { "pattern": "^#[0-9a-f]{6}$", "optional": true },
19
+ "contextCollections": { "pattern": "^[a-z][a-z0-9-]*(?:,[a-z][a-z0-9-]*)*$", "optional": true }
19
20
  },
20
21
  "bindings": [
21
22
  { "kind": "service", "binding": "CHAT", "service": "pmeweb-chat", "entrypoint": "ChatApi" },
@@ -33,6 +34,7 @@
33
34
  },
34
35
  "markup": { "pages": "all", "required": ["#pmw-chat", "[data-pmw-chat-opener]"] },
35
36
  "build": ["plugins/chat/context.json"],
37
+ "context": { "file": "src/chat/context.{lang}.md", "maxChars": 16000 },
36
38
  "portal": {
37
39
  "screens": [
38
40
  { "key": "appearance",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmeweb/plugin-chat",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "PmeWeb chat plugin: the bubble, the lazy chat panel, the site Worker's /api/chat routes and the build-time context file. The conversations, the model and the alerts live in the pmeweb-chat service.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/build.d.ts CHANGED
@@ -1,30 +1,45 @@
1
1
  export declare const CONTEXT_FORMAT: 1
2
2
  export declare const MAX_CONTEXT_BYTES: number
3
+ /** The curated file's budget per language, stamp line excluded. */
4
+ export declare const MAX_CONTEXT_CHARS: number
5
+ export declare const SUMMARY_CHARS: number
6
+ export declare const SUMMARY_FIELDS: readonly string[]
7
+ /** blog and authors: never in the context. */
8
+ export declare const NEVER_COLLECTIONS: readonly string[]
9
+
10
+ type Localized = string | Partial<Record<'fr' | 'en', string>>
3
11
 
4
12
  export interface ChatContextLang {
5
13
  /** The business's name in this language (the bot says it; pmeweb-chat falls back to the host). */
6
14
  business?: string
7
- /** What src/pages/llms.txt.ts renders for this language. */
8
- llms?: string
9
- /** The pricing text (the source of public/pricing.md). */
15
+ /** The text of src/chat/context.<lang>.md (the stamp line is removed here). Required. */
16
+ context: string
17
+ /** The pricing text (the site's pricing source, public/pricing.md on most sites). */
10
18
  pricing?: string
11
- /** src/i18n/<lang>.ts: flattened to `key: value` lines, strings only. */
12
- dictionary?: object
13
19
  }
14
20
 
15
21
  export interface ChatContextEntry {
22
+ /** A collection listed in plugins.chat.contextCollections; never blog or authors. */
16
23
  collection: string
24
+ /** The section's title (default: the collection's name). */
25
+ label?: Localized
17
26
  /** Absent: the entry belongs to every language. */
18
27
  lang?: 'fr' | 'en'
19
28
  title: string
29
+ /** The entry's one line; absent: its summary-like field (SUMMARY_FIELDS), else its body. Cut at 200 characters. */
30
+ summary?: Localized
20
31
  data?: Record<string, unknown>
21
32
  body?: string
22
33
  }
23
34
 
24
- /** The JSON text of plugins/chat/context.json. Deterministic; throws above 200 KB or on an empty language. */
35
+ /** The JSON text of plugins/chat/context.json. Deterministic; throws on a missing or oversized curated file. */
25
36
  export declare function chatContext(sources: {
26
37
  langs: Partial<Record<'fr' | 'en', ChatContextLang>>
27
38
  collections?: ChatContextEntry[]
28
39
  }): string
29
40
 
30
- export declare function flattenDictionary(dict: object, prefix?: string): string
41
+ /** `plugins.chat.contextCollections` as a list of collection names; throws on blog or authors. */
42
+ export declare function contextCollections(value: unknown): string[]
43
+ export declare function entrySummary(entry: ChatContextEntry, lang: 'fr' | 'en'): string
44
+ export declare function stripStamp(md: string): string
45
+ export declare function contextSections(md: string, fallbackTitle: string): { title: string; text: string }[]
package/src/build.js CHANGED
@@ -1,58 +1,125 @@
1
1
  // @pmeweb/plugin-chat/build: the site's context file for pmeweb-chat (manifest `build`:
2
2
  // plugins/chat/context.json, one file for every language). The site renders it from a prerendered
3
- // route with sources it already has (llms.txt, its pricing text, its dictionaries, its published
4
- // collection entries); pmeweb-chat reads `langs[lang]` (src/context.ts there):
3
+ // route; pmeweb-chat reads `langs[lang]` (src/context.ts there):
5
4
  //
6
5
  // { "format": 1, "langs": { "fr": { "business": "...", "sections": [{ "title", "text" }] } } }
7
6
  //
7
+ // Since 1.3.0 the sources are three, and only three (operator decision 2026-11-13):
8
+ // 1. the site's curated file, src/chat/context.<lang>.md (manifest `context`), written by Claude
9
+ // Code for the assistant and held current by check-plugins' fingerprint. Its `## ` headings
10
+ // become the sections; the first-line stamp comment is not sent.
11
+ // 2. the pricing text (the site's pricing source, public/pricing.md on most sites): prices are
12
+ // never copied into the curated file.
13
+ // 3. the entries of the collections the site lists in `plugins.chat.contextCollections`, one line
14
+ // each (title and a short summary, never the body). Blog posts never go in.
15
+ // The dictionary and llms.txt are no longer read (1.2.0 sent them whole: ~26 000 tokens on pmeweb.ca,
16
+ // mostly interface labels).
17
+ //
8
18
  // DETERMINISTIC: same sources, same bytes. No date, no random id, no network, no model call (the
9
19
  // builder holds no secret, and a rebuild must not spend money). Not public: the site's worker serves
10
20
  // it only to a signed request (plugin-kit's pluginArtifacts), never to a visitor.
11
21
 
12
22
  export const CONTEXT_FORMAT = 1
13
- /** About 50k tokens at most: past this the prompt would cost too much per message. */
23
+ /** A last guard on the whole file; the curated file has its own, smaller budget (MAX_CONTEXT_CHARS). */
14
24
  export const MAX_CONTEXT_BYTES = 200 * 1024
25
+ /** The curated file's budget per language, stamp line excluded (equal to the manifest's `context.maxChars`). */
26
+ export const MAX_CONTEXT_CHARS = 16000
27
+ /** An entry's summary is cut here, at a word. */
28
+ export const SUMMARY_CHARS = 200
29
+ /** The fields an entry's summary is taken from, in this order, before its body. */
30
+ export const SUMMARY_FIELDS = ['summary', 'resume', 'résumé', 'description', 'excerpt', 'extrait', 'about', 'apropos', 'intro']
31
+ /** Collections that never go in the context, whatever the config says. */
32
+ export const NEVER_COLLECTIONS = ['blog', 'authors']
15
33
 
16
34
  const LANGS = ['fr', 'en']
17
35
  const TITLES = {
18
- fr: { llms: 'llms.txt', pricing: 'Tarifs', dictionary: 'Textes du site', colon: ' : ' },
19
- en: { llms: 'llms.txt', pricing: 'Pricing', dictionary: 'Site text', colon: ': ' },
36
+ fr: { pricing: 'Tarifs', colon: ' : ' },
37
+ en: { pricing: 'Pricing', colon: ': ' },
20
38
  }
21
39
 
22
40
  const text = (v) => (typeof v === 'string' ? v.trim() : '')
23
41
  const oneLine = (v) => v.replace(/\s*\n\s*/g, ' ').trim()
24
42
 
25
- /** A dictionary as `path.to.key: value` lines, strings only, in the dictionary's own order. */
26
- export function flattenDictionary(dict, prefix = '') {
43
+ /** The curated file's text, its leading HTML comments (the stamp) removed. */
44
+ export function stripStamp(md) {
45
+ return text(md).replace(/^(?:<!--[\s\S]*?-->\s*)+/, '').trim()
46
+ }
47
+
48
+ /**
49
+ * The curated file as sections: one per `## ` heading; what comes before the first one is a
50
+ * section titled by the `# ` heading (else `fallbackTitle`).
51
+ */
52
+ export function contextSections(md, fallbackTitle) {
27
53
  const out = []
28
- const walk = (v, path) => {
29
- if (typeof v === 'string') {
30
- if (v.trim() !== '') out.push(`${path}: ${oneLine(v)}`)
31
- } else if (Array.isArray(v)) {
32
- v.forEach((x, i) => walk(x, path ? `${path}.${i}` : String(i)))
33
- } else if (v && typeof v === 'object') {
34
- for (const [k, x] of Object.entries(v)) walk(x, path ? `${path}.${k}` : k)
35
- }
54
+ let title = fallbackTitle
55
+ let lines = []
56
+ const flush = () => {
57
+ const t = lines.join('\n').trim()
58
+ if (t) out.push({ title, text: t })
59
+ lines = []
60
+ }
61
+ for (const line of stripStamp(md).split(/\r?\n/)) {
62
+ const h1 = /^#\s+(.+?)\s*$/.exec(line)
63
+ const h2 = /^##\s+(.+?)\s*$/.exec(line)
64
+ if (h1 && out.length === 0 && lines.every((l) => l.trim() === '')) { title = h1[1]; continue }
65
+ if (h2) { flush(); title = h2[1]; continue }
66
+ lines.push(line)
36
67
  }
37
- walk(dict, prefix)
38
- return out.join('\n')
68
+ flush()
69
+ return out
70
+ }
71
+
72
+ /** `plugins.chat.contextCollections` ("portfolio,services") as a list; refuses the blog and its authors. */
73
+ export function contextCollections(value) {
74
+ if (value === undefined || value === null || value === '') return []
75
+ if (typeof value !== 'string') throw new Error('chat : contextCollections est une chaîne, des noms de collections séparés par des virgules')
76
+ const names = value.split(',').map((s) => s.trim()).filter(Boolean)
77
+ const never = names.filter((n) => NEVER_COLLECTIONS.includes(n))
78
+ if (never.length) throw new Error(`chat : ${never.join(', ')} n'entre jamais dans le contexte (les articles et leurs auteurs restent sur le site)`)
79
+ return names
39
80
  }
40
81
 
41
- /** One collection entry: its string fields as `key: value` lines (the title itself left out), then its body. */
42
- function entryText(e) {
43
- const lines = []
44
- for (const [k, v] of Object.entries(e.data ?? {})) {
45
- if (typeof v !== 'string' || v.trim() === '' || v === e.title) continue
46
- lines.push(`${k}: ${oneLine(v)}`)
82
+ /** Markdown reduced to plain words: links to their text, images, emphasis and heading marks gone. */
83
+ function plain(md) {
84
+ return md
85
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
86
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
87
+ .replace(/^#{1,6}\s+/gm, '')
88
+ .replace(/[*_`>]+/g, '')
89
+ .replace(/\s+/g, ' ')
90
+ .trim()
91
+ }
92
+
93
+ /** Cut at a word, at most `max` characters plus the ellipsis. */
94
+ function cut(s, max) {
95
+ if (s.length <= max) return s
96
+ const head = s.slice(0, max)
97
+ const at = head.lastIndexOf(' ')
98
+ return `${(at > max * 0.6 ? head.slice(0, at) : head).replace(/[\s,;:.]+$/, '')}…`
99
+ }
100
+
101
+ /** A field's value in `lang`: a string, or a `{ fr, en }` object. */
102
+ function inLang(v, lang) {
103
+ if (typeof v === 'string') return v
104
+ if (v && typeof v === 'object' && typeof v[lang] === 'string') return v[lang]
105
+ return ''
106
+ }
107
+
108
+ /** An entry's one-line summary: `summary`, else its first summary-like field, else its body; 200 characters at most. */
109
+ export function entrySummary(e, lang) {
110
+ let s = inLang(e.summary, lang)
111
+ for (const k of SUMMARY_FIELDS) {
112
+ if (text(s)) break
113
+ s = inLang(e.data?.[k], lang)
47
114
  }
48
- const body = text(e.body)
49
- return [lines.join('\n'), body].filter(Boolean).join('\n\n')
115
+ if (!text(s)) s = text(e.body)
116
+ return cut(plain(oneLine(s)), SUMMARY_CHARS)
50
117
  }
51
118
 
52
119
  /**
53
120
  * @param {{
54
- * langs: Partial<Record<'fr' | 'en', { business?: string, llms?: string, pricing?: string, dictionary?: object }>>,
55
- * collections?: { collection: string, lang?: 'fr' | 'en', title: string, data?: Record<string, unknown>, body?: string }[],
121
+ * langs: Partial<Record<'fr' | 'en', { business?: string, context?: string, pricing?: string }>>,
122
+ * collections?: { collection: string, label?: string | Record<string, string>, lang?: 'fr' | 'en', title: string, summary?: string | Record<string, string>, data?: Record<string, unknown>, body?: string }[],
56
123
  * }} sources an entry with no `lang` belongs to every language
57
124
  * @returns {string} the JSON text of plugins/chat/context.json
58
125
  */
@@ -61,22 +128,38 @@ export function chatContext({ langs = {}, collections = [] } = {}) {
61
128
  if (keys.length === 0 || keys.some((l) => !LANGS.includes(l))) {
62
129
  throw new Error(`chat : une langue fr ou en au moins, et aucune autre (reçu ${keys.join(', ') || 'rien'})`)
63
130
  }
131
+ for (const l of keys) {
132
+ const old = ['llms', 'dictionary'].filter((k) => langs[l]?.[k] !== undefined)
133
+ if (old.length) throw new Error(`chat : ${old.join(' et ')} ne sont plus lus depuis 1.3.0 ; passer context (src/chat/context.${l}.md), voir INSTALL.md « Updating from 1.2.1 »`)
134
+ }
135
+ for (const e of collections) {
136
+ if (NEVER_COLLECTIONS.includes(e.collection)) throw new Error(`chat : la collection ${e.collection} n'entre jamais dans le contexte`)
137
+ }
64
138
  const out = { format: CONTEXT_FORMAT, langs: {} }
65
139
  for (const lang of LANGS.filter((l) => keys.includes(l))) {
66
140
  const src = langs[lang] ?? {}
67
141
  const t = TITLES[lang]
68
- const sections = []
69
- const add = (title, body) => { if (body) sections.push({ title, text: body }) }
70
- add(t.llms, text(src.llms))
71
- add(t.pricing, text(src.pricing))
72
- add(t.dictionary, src.dictionary ? flattenDictionary(src.dictionary) : '')
142
+ const business = text(src.business)
143
+ const curated = stripStamp(src.context ?? '')
144
+ if (!curated) throw new Error(`chat : src/chat/context.${lang}.md absent ou vide (le contexte de l'assistant)`)
145
+ if (curated.length > MAX_CONTEXT_CHARS) throw new Error(`chat : src/chat/context.${lang}.md fait ${curated.length} caractères, au-delà de ${MAX_CONTEXT_CHARS}`)
146
+ const sections = contextSections(curated, business || (lang === 'fr' ? 'Entreprise' : 'Business'))
147
+ const pricing = text(src.pricing)
148
+ if (pricing) sections.push({ title: t.pricing, text: pricing })
149
+ // One section per collection, one line per entry, in the order the site gives them.
150
+ const groups = new Map()
73
151
  for (const e of collections) {
74
152
  if (e.lang !== undefined && e.lang !== lang) continue
75
- add(`${e.collection}${t.colon}${text(e.title)}`, entryText(e))
153
+ const title = oneLine(text(e.title))
154
+ if (!title) continue
155
+ const label = inLang(e.label, lang) || e.collection
156
+ if (!groups.has(label)) groups.set(label, [])
157
+ const summary = entrySummary(e, lang)
158
+ groups.get(label).push(summary ? `- ${title}${t.colon}${summary}` : `- ${title}`)
76
159
  }
77
- if (sections.length === 0) throw new Error(`chat : contexte ${lang} vide`)
160
+ for (const [label, lines] of groups) sections.push({ title: label, text: lines.join('\n') })
78
161
  const part = {}
79
- if (text(src.business)) part.business = text(src.business)
162
+ if (business) part.business = business
80
163
  part.sections = sections
81
164
  out.langs[lang] = part
82
165
  }
package/src/worker.js CHANGED
@@ -32,7 +32,7 @@ import { verifyTurnstile } from '@pmeweb/turnstile/worker'
32
32
  import { RPC_HEADER, RPC_VERSION } from './protocol.js'
33
33
 
34
34
  /** This package's version, reported by the health route (a test holds it equal to package.json). */
35
- export const VERSION = '1.2.1'
35
+ export const VERSION = '1.3.0'
36
36
  export { RPC_VERSION }
37
37
 
38
38
  const SERVICE = 'https://pmeweb-chat.internal'