@dxos/assistant 0.9.0 → 0.9.1-main.c7dcc2e112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/lib/neutral/extraction/index.mjs +108 -119
  2. package/dist/lib/neutral/extraction/index.mjs.map +4 -4
  3. package/dist/lib/neutral/index.mjs +17 -13
  4. package/dist/lib/neutral/index.mjs.map +4 -4
  5. package/dist/lib/neutral/meta.json +1 -1
  6. package/dist/types/src/extraction/extraction-llm-function/index.d.ts +2 -0
  7. package/dist/types/src/extraction/extraction-llm-function/index.d.ts.map +1 -0
  8. package/dist/types/src/extraction/extraction-llm-function/proper-noun-extraction.d.ts +227 -0
  9. package/dist/types/src/extraction/extraction-llm-function/proper-noun-extraction.d.ts.map +1 -0
  10. package/dist/types/src/extraction/index.d.ts +1 -1
  11. package/dist/types/src/extraction/index.d.ts.map +1 -1
  12. package/dist/types/src/extraction/quotes.d.ts +8 -1
  13. package/dist/types/src/extraction/quotes.d.ts.map +1 -1
  14. package/dist/types/src/request/AiRequest.d.ts +6 -0
  15. package/dist/types/src/request/AiRequest.d.ts.map +1 -1
  16. package/dist/types/src/session/AiSession.d.ts +6 -0
  17. package/dist/types/src/session/AiSession.d.ts.map +1 -1
  18. package/dist/types/tsconfig.tsbuildinfo +1 -1
  19. package/package.json +21 -21
  20. package/src/extraction/extraction-llm-function/index.ts +5 -0
  21. package/src/extraction/extraction-llm-function/proper-noun-extraction.ts +78 -0
  22. package/src/extraction/index.ts +1 -1
  23. package/src/extraction/quotes.test.ts +67 -2
  24. package/src/extraction/quotes.ts +47 -13
  25. package/src/request/AiRequest.ts +10 -0
  26. package/src/session/AiSession.ts +8 -0
  27. package/dist/types/src/extraction/extraction.d.ts +0 -1677
  28. package/dist/types/src/extraction/extraction.d.ts.map +0 -1
  29. package/src/extraction/extraction-llm-function/transcription-instructions.tpl +0 -28
  30. package/src/extraction/extraction.ts +0 -105
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/assistant",
3
- "version": "0.9.0",
3
+ "version": "0.9.1-main.c7dcc2e112",
4
4
  "description": "Natural language interface to DXOS",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -38,26 +38,26 @@
38
38
  "@effect/platform": "0.96.1",
39
39
  "@xenova/transformers": "^2.17.2",
40
40
  "lodash.defaultsdeep": "^4.6.1",
41
- "@dxos/async": "0.9.0",
42
- "@dxos/compute": "0.9.0",
43
- "@dxos/context": "0.9.0",
44
- "@dxos/debug": "0.9.0",
45
- "@dxos/echo": "0.9.0",
46
- "@dxos/echo-client": "0.9.0",
47
- "@dxos/echo-protocol": "0.9.0",
48
- "@dxos/effect": "0.9.0",
49
- "@dxos/errors": "0.9.0",
50
- "@dxos/functions": "0.9.0",
51
- "@dxos/graph": "0.9.0",
52
- "@dxos/invariant": "0.9.0",
53
- "@dxos/log": "0.9.0",
54
- "@dxos/keys": "0.9.0",
55
- "@dxos/mcp-client": "0.9.0",
56
- "@dxos/node-std": "0.9.0",
57
- "@dxos/schema": "0.9.0",
58
- "@dxos/types": "0.9.0",
59
- "@dxos/util": "0.9.0",
60
- "@dxos/ai": "0.9.0"
41
+ "@dxos/ai": "0.9.1-main.c7dcc2e112",
42
+ "@dxos/async": "0.9.1-main.c7dcc2e112",
43
+ "@dxos/compute": "0.9.1-main.c7dcc2e112",
44
+ "@dxos/context": "0.9.1-main.c7dcc2e112",
45
+ "@dxos/debug": "0.9.1-main.c7dcc2e112",
46
+ "@dxos/echo": "0.9.1-main.c7dcc2e112",
47
+ "@dxos/echo-client": "0.9.1-main.c7dcc2e112",
48
+ "@dxos/errors": "0.9.1-main.c7dcc2e112",
49
+ "@dxos/echo-protocol": "0.9.1-main.c7dcc2e112",
50
+ "@dxos/graph": "0.9.1-main.c7dcc2e112",
51
+ "@dxos/keys": "0.9.1-main.c7dcc2e112",
52
+ "@dxos/functions": "0.9.1-main.c7dcc2e112",
53
+ "@dxos/log": "0.9.1-main.c7dcc2e112",
54
+ "@dxos/mcp-client": "0.9.1-main.c7dcc2e112",
55
+ "@dxos/invariant": "0.9.1-main.c7dcc2e112",
56
+ "@dxos/node-std": "0.9.1-main.c7dcc2e112",
57
+ "@dxos/effect": "0.9.1-main.c7dcc2e112",
58
+ "@dxos/types": "0.9.1-main.c7dcc2e112",
59
+ "@dxos/schema": "0.9.1-main.c7dcc2e112",
60
+ "@dxos/util": "0.9.1-main.c7dcc2e112"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/lodash.defaultsdeep": "^4.6.6",
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './proper-noun-extraction';
@@ -0,0 +1,78 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as LanguageModel from '@effect/ai/LanguageModel';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Schema from 'effect/Schema';
8
+
9
+ import { AiService } from '@dxos/ai';
10
+ import { type Database, Obj } from '@dxos/echo';
11
+ import { Message } from '@dxos/types';
12
+
13
+ import { findReferences, insertReferences } from '../quotes';
14
+
15
+ const EXTRACTION_MODEL = 'ai.claude.model.claude-haiku-4-5';
16
+
17
+ /**
18
+ * Proper nouns extracted from transcript text.
19
+ */
20
+ export const ProperNouns = Schema.Struct({
21
+ properNouns: Schema.Array(Schema.String).annotations({
22
+ description: 'Proper nouns (names of people, organizations, places, products) mentioned in the text.',
23
+ }),
24
+ });
25
+ export type ProperNouns = Schema.Schema.Type<typeof ProperNouns>;
26
+
27
+ /**
28
+ * Extracts proper nouns from transcript text using a small LLM (Haiku). Provides the LanguageModel
29
+ * internally so the residual requirement is just {@link AiService.AiService}.
30
+ */
31
+ export const extractProperNouns = (text: string) =>
32
+ Effect.gen(function* () {
33
+ const { value } = yield* Effect.scoped(
34
+ LanguageModel.generateObject({
35
+ schema: ProperNouns,
36
+ prompt: [
37
+ 'Extract every proper noun (people, organizations, places, products) mentioned in the transcript text below.',
38
+ 'Return only the surface strings exactly as they appear. Exclude common nouns and pronouns.',
39
+ '',
40
+ 'Transcript:',
41
+ text,
42
+ ].join('\n'),
43
+ }),
44
+ );
45
+
46
+ // Drop short tokens (e.g. "IT"): insertReferences replaces case-insensitive substrings, so a
47
+ // short noun would match inside unrelated words ("secur[IT]y") and corrupt neighbouring links.
48
+ return value.properNouns.map((noun) => noun.trim()).filter((noun) => noun.length >= 3);
49
+ }).pipe(Effect.provide(AiService.model(EXTRACTION_MODEL)));
50
+
51
+ /**
52
+ * Enriches a transcript message: for each transcript block, extracts proper nouns, links them to
53
+ * objects in the space via full-text search, and rewrites the text with `[noun](dxn)` references.
54
+ * Requires {@link AiService.AiService}; takes the space database handle.
55
+ */
56
+ export const enrichTranscriptMessage = (message: Message.Message, { db }: { db: Database.Database }) =>
57
+ Effect.gen(function* () {
58
+ const blocks = yield* Effect.forEach(message.blocks, (block) =>
59
+ Effect.gen(function* () {
60
+ if (block._tag !== 'transcript') {
61
+ return block;
62
+ }
63
+
64
+ const nouns = yield* extractProperNouns(block.text);
65
+ const references = yield* Effect.promise(() => findReferences([...nouns], db));
66
+ return { ...block, text: insertReferences(block.text, references) };
67
+ }),
68
+ );
69
+
70
+ // Preserve the message identity and metadata; enrichment only rewrites transcript block text.
71
+ return Obj.make(Message.Message, {
72
+ id: message.id,
73
+ sender: message.sender,
74
+ created: message.created,
75
+ properties: message.properties,
76
+ blocks,
77
+ });
78
+ });
@@ -2,6 +2,6 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- export * from './extraction';
5
+ export * from './extraction-llm-function';
6
6
  export * from './extraction-ner-function';
7
7
  export * from './quotes';
@@ -2,9 +2,13 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { describe, expect, test } from 'vitest';
5
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest';
6
6
 
7
- import { insertReferences } from './quotes';
7
+ import { Obj } from '@dxos/echo';
8
+ import { EchoTestBuilder } from '@dxos/echo-client/testing';
9
+ import { Organization, Person } from '@dxos/types';
10
+
11
+ import { findReferences, insertReferences } from './quotes';
8
12
 
9
13
  describe('insertReferences', () => {
10
14
  test('should replace quotes with DXN references', () => {
@@ -24,4 +28,65 @@ describe('insertReferences', () => {
24
28
  "And what I'd like to talk today about is Steven Wolfram's concept of a [computational irreducibility](echo:/01JTG9JW11XGWJZ32AW8ET93D1).",
25
29
  );
26
30
  });
31
+
32
+ test('does not nest links when one quote is a substring of another', () => {
33
+ const quotes = {
34
+ references: [
35
+ { quote: 'Sarah', id: '01JTG9JW11XGWJZ32AW8ET93D1' },
36
+ { quote: 'Sarah Johnson', id: '01JTG9JW11XGWJZ32AW8ET93D1' },
37
+ ],
38
+ };
39
+
40
+ // "Sarah Johnson" is linked as a whole (processed first); the standalone "Sarah" is linked
41
+ // separately; the "Sarah" inside the first link is left untouched (no nested `[[..](..)](..)`).
42
+ expect(insertReferences('Sarah Johnson spoke. Later Sarah left.', quotes)).toBe(
43
+ '[Sarah Johnson](echo:/01JTG9JW11XGWJZ32AW8ET93D1) spoke. Later [Sarah](echo:/01JTG9JW11XGWJZ32AW8ET93D1) left.',
44
+ );
45
+ });
46
+
47
+ test('does not re-link a repeated quote inside an already-inserted link', () => {
48
+ const quotes = { references: [{ quote: 'Amco', id: '01JTG9JW11XGWJZ32AW8ET93D1' }] };
49
+
50
+ expect(insertReferences('Amco and Amco again.', quotes)).toBe(
51
+ '[Amco](echo:/01JTG9JW11XGWJZ32AW8ET93D1) and [Amco](echo:/01JTG9JW11XGWJZ32AW8ET93D1) again.',
52
+ );
53
+ });
54
+ });
55
+
56
+ describe('findReferences', () => {
57
+ let builder: EchoTestBuilder;
58
+
59
+ beforeEach(async () => {
60
+ builder = await new EchoTestBuilder().open();
61
+ });
62
+
63
+ afterEach(async () => {
64
+ await builder.close();
65
+ });
66
+
67
+ test('links nouns to objects via full-text search, including first-name tokens', async () => {
68
+ const { db } = await builder.createDatabase({ types: [Organization.Organization, Person.Person] });
69
+ const amco = db.add(Obj.make(Organization.Organization, { name: 'Amco' }));
70
+ const sarah = db.add(Obj.make(Person.Person, { fullName: 'Sarah Johnson' }));
71
+ await db.flush({ indexes: true });
72
+
73
+ const { references } = await findReferences(['Amco', 'Sarah', 'Nonexistent'], db);
74
+ const byQuote = new Map(references.map((reference) => [reference.quote, reference.id]));
75
+
76
+ expect(byQuote.get('Amco')).toEqual(amco.id);
77
+ // Full-text matches a single token within a multi-word field, so first-name mentions link to
78
+ // the full-name object — this is also what makes the overlap case in `insertReferences` real.
79
+ expect(byQuote.get('Sarah')).toEqual(sarah.id);
80
+ expect(byQuote.has('Nonexistent')).toBe(false);
81
+ });
82
+
83
+ test('deduplicates and trims nouns before lookup', async () => {
84
+ const { db } = await builder.createDatabase({ types: [Organization.Organization] });
85
+ db.add(Obj.make(Organization.Organization, { name: 'Amco' }));
86
+ await db.flush({ indexes: true });
87
+
88
+ const { references } = await findReferences(['Amco', 'Amco', ' Amco ', ''], db);
89
+
90
+ expect(references.filter((reference) => reference.quote === 'Amco')).toHaveLength(1);
91
+ });
27
92
  });
@@ -6,7 +6,6 @@ import * as Schema from 'effect/Schema';
6
6
 
7
7
  import { type Database, Filter, Query } from '@dxos/echo';
8
8
  import { EID, EntityId } from '@dxos/keys';
9
- import { log } from '@dxos/log';
10
9
 
11
10
  export const ReferencedQuotes = Schema.Struct({
12
11
  references: Schema.Array(
@@ -26,12 +25,30 @@ export const ReferencedQuotes = Schema.Struct({
26
25
  });
27
26
  export type ReferencedQuotes = Schema.Schema.Type<typeof ReferencedQuotes>;
28
27
 
29
- export const findQuotes = async (quotes: string[], db: Database.Database): Promise<ReferencedQuotes> => {
28
+ export type FindReferencesOptions = {
29
+ /** Search backend. Full-text matches proper nouns precisely; vector matches semantically. */
30
+ searchKind?: 'full-text' | 'vector';
31
+ };
32
+
33
+ /**
34
+ * Finds the best-matching context object for each noun and returns it as a reference.
35
+ */
36
+ export const findReferences = async (
37
+ nouns: string[],
38
+ db: Database.Database,
39
+ { searchKind = 'full-text' }: FindReferencesOptions = {},
40
+ ): Promise<ReferencedQuotes> => {
41
+ // Deduplicate and process longer nouns first: `insertReferences` rewrites case-insensitive
42
+ // substrings, so a repeated or shorter overlapping noun would re-replace already-linked text and
43
+ // produce nested/broken markdown links.
44
+ const normalizedNouns = Array.from(new Set(nouns.map((noun) => noun.trim()).filter((noun) => noun.length > 0))).sort(
45
+ (leftNoun, rightNoun) => rightNoun.length - leftNoun.length,
46
+ );
47
+
30
48
  const references = await Promise.all(
31
- quotes.map(async (quote) => {
32
- const objects = await db.query(Query.select(Filter.text(quote, { type: 'vector' }))).run();
33
- log.info('found quotes in vector index', { objects, quote });
34
- return objects.length > 0 ? [{ id: objects[0].id.toString(), quote }] : [];
49
+ normalizedNouns.map(async (noun) => {
50
+ const objects = await db.query(Query.select(Filter.text(noun, { type: searchKind }))).run();
51
+ return objects.length > 0 ? [{ id: objects[0].id.toString(), quote: noun }] : [];
35
52
  }),
36
53
  );
37
54
  return { references: references.flat() };
@@ -41,16 +58,33 @@ export const findQuotes = async (quotes: string[], db: Database.Database): Promi
41
58
  * Finds and replaces all quotes with DXNs references.
42
59
  */
43
60
  // TODO(dmaretskyi): Lookup and verifiy ids from provided context.
44
- export const insertReferences = (text: string, quotes: ReferencedQuotes) => {
45
- for (const quote of quotes.references) {
46
- if (!EntityId.isValid(quote.id)) {
47
- continue;
48
- }
61
+ export const insertReferences = (text: string, quotes: ReferencedQuotes): string => {
62
+ // Process longer quotes first so e.g. "Sarah Johnson" is linked before "Sarah". Combined with the
63
+ // link-aware replacement below this stops a shorter or overlapping quote from rewriting text that
64
+ // is already part of an inserted link (which would produce nested `[[..](..)](..)` markdown).
65
+ const references = quotes.references
66
+ .filter((quote) => EntityId.isValid(quote.id))
67
+ .sort((left, right) => right.quote.length - left.quote.length);
49
68
 
50
- // Use a case-insensitive regular expression to replace the quote.
69
+ for (const quote of references) {
70
+ // Case-insensitive match of the (regex-escaped) quote.
51
71
  const regex = new RegExp(quote.quote.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
52
- text = text.replace(regex, `[${quote.quote}](${EID.make({ entityId: quote.id })})`);
72
+ text = replaceOutsideLinks(text, regex, `[${quote.quote}](${EID.make({ entityId: quote.id })})`);
53
73
  }
54
74
 
55
75
  return text;
56
76
  };
77
+
78
+ const MARKDOWN_LINK = /\[[^\]]*\]\([^)]*\)/g;
79
+
80
+ /** Applies a replacement to every span of `text` that is not already inside a `[label](url)` link. */
81
+ const replaceOutsideLinks = (text: string, regex: RegExp, replacement: string): string => {
82
+ let result = '';
83
+ let plainStart = 0;
84
+ for (const link of text.matchAll(MARKDOWN_LINK)) {
85
+ const index = link.index ?? 0;
86
+ result += text.slice(plainStart, index).replace(regex, replacement) + link[0];
87
+ plainStart = index + link[0].length;
88
+ }
89
+ return result + text.slice(plainStart).replace(regex, replacement);
90
+ };
@@ -60,6 +60,13 @@ export type Options = {
60
60
  * This is useful for streaming the output to a queue.
61
61
  */
62
62
  onOutput?: (message: Message.Message) => Effect.Effect<void, never, never>;
63
+
64
+ /**
65
+ * When false, turn messages are not appended to the feed or written as persisted trace blocks.
66
+ *
67
+ * @default true
68
+ */
69
+ persist?: boolean;
63
70
  };
64
71
 
65
72
  export type RunProps<R = never> = {
@@ -139,6 +146,9 @@ export class Request {
139
146
  Effect.gen(this, function* () {
140
147
  this._pending.push(message);
141
148
  yield* this._observer.onMessage(message);
149
+ if (this._options.persist === false) {
150
+ return message;
151
+ }
142
152
  for (const block of message.blocks) {
143
153
  log('write complete block', {
144
154
  messageId: message.id,
@@ -41,6 +41,13 @@ export type RunProps<R = never> = {
41
41
  * Space-level MCP servers to connect alongside blueprint-defined ones.
42
42
  */
43
43
  mcpServers?: readonly McpServer.McpServer[];
44
+
45
+ /**
46
+ * When false, messages from this request are not appended to the feed or persisted to trace.
47
+ *
48
+ * @default true
49
+ */
50
+ persist?: boolean;
44
51
  };
45
52
 
46
53
  export type Options = {
@@ -145,6 +152,7 @@ export class Session extends Resource {
145
152
  const request = new AiRequest.Request({
146
153
  summarizationThreshold: SUMMARY_THRESHOLD,
147
154
  observer: params.observer,
155
+ persist: params.persist,
148
156
  onOutput: (message) =>
149
157
  Effect.promise(() => Runtime.runPromise(this._runtime)(Feed.append(this._feed, [message]))),
150
158
  });