@matrajs/ai 0.0.1

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/README.md ADDED
@@ -0,0 +1,12 @@
1
+ # @matrajs/ai
2
+
3
+ Streaming AI edits that survive concurrent typing, built on `ctx.mark()`.
4
+
5
+ ```bash
6
+ npm i matra @matrajs/ai
7
+ ```
8
+
9
+ - Docs: https://matrajs.com
10
+ - Source: https://github.com/amrelaco/matra
11
+
12
+ MIT.
package/dist/index.cjs ADDED
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ // src/extension.ts
4
+ function ai(options) {
5
+ let live = null;
6
+ let nextId = 1;
7
+ let editorRef = null;
8
+ const snapshot = () => ({
9
+ id: live?.id ?? 0,
10
+ status: live?.status ?? "idle",
11
+ range: live ? live.marker.mapRange(live.captured) : { from: 0, to: 0 },
12
+ received: live?.received ?? "",
13
+ error: live?.error
14
+ });
15
+ const report = (status) => {
16
+ if (live) live.status = status;
17
+ options.onStatus?.(snapshot());
18
+ };
19
+ const applyChunk = (editor, session) => {
20
+ const target = session.marker.mapRange(session.captured);
21
+ editor.commands.replace(target, session.received);
22
+ };
23
+ const askAi = (ctx, instruction) => {
24
+ if (live && live.status === "streaming") return false;
25
+ const editor = editorRef;
26
+ if (!editor) return false;
27
+ const { from, to } = ctx.selection;
28
+ if (from === to) return false;
29
+ const selected = selectedText(ctx);
30
+ if (!selected) return false;
31
+ const session = {
32
+ id: nextId++,
33
+ marker: ctx.mark(),
34
+ captured: { from, to },
35
+ received: "",
36
+ status: "streaming",
37
+ controller: new AbortController()
38
+ };
39
+ live = session;
40
+ report("streaming");
41
+ void (async () => {
42
+ const request = {
43
+ text: selected,
44
+ instruction,
45
+ signal: session.controller.signal
46
+ };
47
+ try {
48
+ for await (const chunk of options.stream(request)) {
49
+ if (session.controller.signal.aborted || live?.id !== session.id) return;
50
+ session.received += chunk;
51
+ applyChunk(editor, session);
52
+ report("streaming");
53
+ }
54
+ if (live?.id === session.id) report("done");
55
+ } catch (error) {
56
+ if (live?.id !== session.id) return;
57
+ session.error = error instanceof Error ? error : new Error(String(error));
58
+ report(session.controller.signal.aborted ? "cancelled" : "error");
59
+ }
60
+ })();
61
+ return true;
62
+ };
63
+ const cancelAi = () => {
64
+ if (!live || live.status !== "streaming") return false;
65
+ live.controller.abort();
66
+ report("cancelled");
67
+ return true;
68
+ };
69
+ const acceptAi = () => {
70
+ if (!live) return false;
71
+ live = null;
72
+ options.onStatus?.(snapshot());
73
+ return true;
74
+ };
75
+ const rejectAi = (ctx) => {
76
+ if (!live) return false;
77
+ live.controller.abort();
78
+ const target = live.marker.mapRange(live.captured);
79
+ live = null;
80
+ options.onStatus?.(snapshot());
81
+ return ctx.select(target);
82
+ };
83
+ return {
84
+ kind: "extension",
85
+ name: "ai",
86
+ commands: { askAi, cancelAi, acceptAi, rejectAi },
87
+ onCreate(editor) {
88
+ editorRef = editor;
89
+ },
90
+ onDestroy() {
91
+ live?.controller.abort();
92
+ live = null;
93
+ editorRef = null;
94
+ }
95
+ };
96
+ }
97
+ function selectedText(ctx) {
98
+ const { from, to } = ctx.selection;
99
+ const parts = [];
100
+ let offset = 0;
101
+ const walk = (node) => {
102
+ if (typeof node.text === "string") {
103
+ const start = offset;
104
+ const end = offset + node.text.length;
105
+ if (end > from && start < to) {
106
+ parts.push(node.text.slice(Math.max(0, from - start), Math.max(0, to - start)));
107
+ }
108
+ offset = end;
109
+ return;
110
+ }
111
+ offset += 1;
112
+ for (const child of node.content ?? []) {
113
+ walk(child);
114
+ }
115
+ offset += 1;
116
+ };
117
+ walk(ctx.doc);
118
+ return parts.join("");
119
+ }
120
+
121
+ exports.ai = ai;
122
+ //# sourceMappingURL=index.cjs.map
123
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/extension.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type { Command, Ctx, Editor, ExtensionDef, PosMarker, Range } from '@matrajs/core'\nimport type { AiRequest, AiSession, AiStatus, AiStream } from './types'\n\nexport interface AiOptions {\n /** Where the text comes from. Keep the model key on your server, not here. */\n stream: AiStream\n /** Called on every status change, for spinners and error toasts. */\n onStatus?: (session: AiSession) => void\n}\n\ninterface Live {\n id: number\n marker: PosMarker\n /** The range as captured when the request started. */\n captured: Range\n received: string\n status: AiStatus\n controller: AbortController\n error?: Error\n}\n\n/**\n * Streaming AI edits that survive concurrent typing.\n *\n * The whole point of this extension is the marker. A request captures the range\n * it is rewriting *and* a position marker; every chunk that comes back is\n * applied to `marker.mapRange(captured)`, not to the original numbers. The user\n * can type, delete and paste anywhere while the model is talking, and the\n * answer still lands on the words they selected.\n */\nexport function ai(options: AiOptions): ExtensionDef<{\n askAi: Command<[instruction: string]>\n cancelAi: Command\n acceptAi: Command\n rejectAi: Command\n}> {\n let live: Live | null = null\n let nextId = 1\n let editorRef: Editor | null = null\n\n const snapshot = (): AiSession => ({\n id: live?.id ?? 0,\n status: live?.status ?? 'idle',\n range: live ? live.marker.mapRange(live.captured) : { from: 0 as never, to: 0 as never },\n received: live?.received ?? '',\n error: live?.error,\n })\n\n const report = (status: AiStatus) => {\n if (live) live.status = status\n options.onStatus?.(snapshot())\n }\n\n /** Replace the live range with whatever has arrived so far. */\n const applyChunk = (editor: Editor, session: Live) => {\n const target = session.marker.mapRange(session.captured)\n editor.commands.replace(target, session.received)\n }\n\n const askAi: Command<[string]> = (ctx, instruction) => {\n if (live && live.status === 'streaming') return false\n const editor = editorRef\n if (!editor) return false\n\n const { from, to } = ctx.selection\n if (from === to) return false\n\n const selected = selectedText(ctx)\n if (!selected) return false\n\n const session: Live = {\n id: nextId++,\n marker: ctx.mark(),\n captured: { from, to },\n received: '',\n status: 'streaming',\n controller: new AbortController(),\n }\n live = session\n report('streaming')\n\n void (async () => {\n const request: AiRequest = {\n text: selected,\n instruction,\n signal: session.controller.signal,\n }\n try {\n for await (const chunk of options.stream(request)) {\n if (session.controller.signal.aborted || live?.id !== session.id) return\n session.received += chunk\n applyChunk(editor, session)\n report('streaming')\n }\n if (live?.id === session.id) report('done')\n } catch (error) {\n if (live?.id !== session.id) return\n session.error = error instanceof Error ? error : new Error(String(error))\n report(session.controller.signal.aborted ? 'cancelled' : 'error')\n }\n })()\n\n return true\n }\n\n const cancelAi: Command = () => {\n if (!live || live.status !== 'streaming') return false\n live.controller.abort()\n report('cancelled')\n return true\n }\n\n const acceptAi: Command = () => {\n if (!live) return false\n live = null\n options.onStatus?.(snapshot())\n return true\n }\n\n /** Put back what was there before the model started talking. */\n const rejectAi: Command = (ctx) => {\n if (!live) return false\n live.controller.abort()\n const target = live.marker.mapRange(live.captured)\n live = null\n options.onStatus?.(snapshot())\n // The original text is recovered by undoing the streamed replacements.\n return ctx.select(target)\n }\n\n return {\n kind: 'extension',\n name: 'ai',\n commands: { askAi, cancelAi, acceptAi, rejectAi },\n onCreate(editor) {\n editorRef = editor as Editor\n },\n onDestroy() {\n live?.controller.abort()\n live = null\n editorRef = null\n },\n }\n}\n\n/** The plain text inside the current selection. */\nfunction selectedText(ctx: Ctx): string {\n const { from, to } = ctx.selection\n const parts: string[] = []\n let offset = 0\n\n const walk = (node: { text?: string; content?: unknown[] }) => {\n if (typeof node.text === 'string') {\n const start = offset\n const end = offset + node.text.length\n if (end > from && start < to) {\n parts.push(node.text.slice(Math.max(0, from - start), Math.max(0, to - start)))\n }\n offset = end\n return\n }\n offset += 1\n for (const child of (node.content ?? []) as { text?: string; content?: unknown[] }[]) {\n walk(child)\n }\n offset += 1\n }\n\n walk(ctx.doc as { text?: string; content?: unknown[] })\n return parts.join('')\n}\n"]}
@@ -0,0 +1,45 @@
1
+ import { Range, ExtensionDef, Command } from '@matrajs/core';
2
+
3
+ /** What the model is asked to do and what it streams back. */
4
+ interface AiRequest {
5
+ /** The text currently selected, which the model is asked to act on. */
6
+ text: string;
7
+ /** Free-form instruction: "make this shorter", "fix the grammar". */
8
+ instruction: string;
9
+ signal: AbortSignal;
10
+ }
11
+ /** Any function that streams a replacement, chunk by chunk. */
12
+ type AiStream = (request: AiRequest) => AsyncIterable<string>;
13
+ type AiStatus = 'idle' | 'streaming' | 'done' | 'error' | 'cancelled';
14
+ interface AiSession {
15
+ readonly id: number;
16
+ readonly status: AiStatus;
17
+ /** The range being rewritten, re-resolved against the current document. */
18
+ readonly range: Range;
19
+ readonly received: string;
20
+ readonly error?: Error;
21
+ }
22
+
23
+ interface AiOptions {
24
+ /** Where the text comes from. Keep the model key on your server, not here. */
25
+ stream: AiStream;
26
+ /** Called on every status change, for spinners and error toasts. */
27
+ onStatus?: (session: AiSession) => void;
28
+ }
29
+ /**
30
+ * Streaming AI edits that survive concurrent typing.
31
+ *
32
+ * The whole point of this extension is the marker. A request captures the range
33
+ * it is rewriting *and* a position marker; every chunk that comes back is
34
+ * applied to `marker.mapRange(captured)`, not to the original numbers. The user
35
+ * can type, delete and paste anywhere while the model is talking, and the
36
+ * answer still lands on the words they selected.
37
+ */
38
+ declare function ai(options: AiOptions): ExtensionDef<{
39
+ askAi: Command<[instruction: string]>;
40
+ cancelAi: Command;
41
+ acceptAi: Command;
42
+ rejectAi: Command;
43
+ }>;
44
+
45
+ export { type AiOptions, type AiRequest, type AiSession, type AiStatus, type AiStream, ai };
@@ -0,0 +1,45 @@
1
+ import { Range, ExtensionDef, Command } from '@matrajs/core';
2
+
3
+ /** What the model is asked to do and what it streams back. */
4
+ interface AiRequest {
5
+ /** The text currently selected, which the model is asked to act on. */
6
+ text: string;
7
+ /** Free-form instruction: "make this shorter", "fix the grammar". */
8
+ instruction: string;
9
+ signal: AbortSignal;
10
+ }
11
+ /** Any function that streams a replacement, chunk by chunk. */
12
+ type AiStream = (request: AiRequest) => AsyncIterable<string>;
13
+ type AiStatus = 'idle' | 'streaming' | 'done' | 'error' | 'cancelled';
14
+ interface AiSession {
15
+ readonly id: number;
16
+ readonly status: AiStatus;
17
+ /** The range being rewritten, re-resolved against the current document. */
18
+ readonly range: Range;
19
+ readonly received: string;
20
+ readonly error?: Error;
21
+ }
22
+
23
+ interface AiOptions {
24
+ /** Where the text comes from. Keep the model key on your server, not here. */
25
+ stream: AiStream;
26
+ /** Called on every status change, for spinners and error toasts. */
27
+ onStatus?: (session: AiSession) => void;
28
+ }
29
+ /**
30
+ * Streaming AI edits that survive concurrent typing.
31
+ *
32
+ * The whole point of this extension is the marker. A request captures the range
33
+ * it is rewriting *and* a position marker; every chunk that comes back is
34
+ * applied to `marker.mapRange(captured)`, not to the original numbers. The user
35
+ * can type, delete and paste anywhere while the model is talking, and the
36
+ * answer still lands on the words they selected.
37
+ */
38
+ declare function ai(options: AiOptions): ExtensionDef<{
39
+ askAi: Command<[instruction: string]>;
40
+ cancelAi: Command;
41
+ acceptAi: Command;
42
+ rejectAi: Command;
43
+ }>;
44
+
45
+ export { type AiOptions, type AiRequest, type AiSession, type AiStatus, type AiStream, ai };
package/dist/index.js ADDED
@@ -0,0 +1,121 @@
1
+ // src/extension.ts
2
+ function ai(options) {
3
+ let live = null;
4
+ let nextId = 1;
5
+ let editorRef = null;
6
+ const snapshot = () => ({
7
+ id: live?.id ?? 0,
8
+ status: live?.status ?? "idle",
9
+ range: live ? live.marker.mapRange(live.captured) : { from: 0, to: 0 },
10
+ received: live?.received ?? "",
11
+ error: live?.error
12
+ });
13
+ const report = (status) => {
14
+ if (live) live.status = status;
15
+ options.onStatus?.(snapshot());
16
+ };
17
+ const applyChunk = (editor, session) => {
18
+ const target = session.marker.mapRange(session.captured);
19
+ editor.commands.replace(target, session.received);
20
+ };
21
+ const askAi = (ctx, instruction) => {
22
+ if (live && live.status === "streaming") return false;
23
+ const editor = editorRef;
24
+ if (!editor) return false;
25
+ const { from, to } = ctx.selection;
26
+ if (from === to) return false;
27
+ const selected = selectedText(ctx);
28
+ if (!selected) return false;
29
+ const session = {
30
+ id: nextId++,
31
+ marker: ctx.mark(),
32
+ captured: { from, to },
33
+ received: "",
34
+ status: "streaming",
35
+ controller: new AbortController()
36
+ };
37
+ live = session;
38
+ report("streaming");
39
+ void (async () => {
40
+ const request = {
41
+ text: selected,
42
+ instruction,
43
+ signal: session.controller.signal
44
+ };
45
+ try {
46
+ for await (const chunk of options.stream(request)) {
47
+ if (session.controller.signal.aborted || live?.id !== session.id) return;
48
+ session.received += chunk;
49
+ applyChunk(editor, session);
50
+ report("streaming");
51
+ }
52
+ if (live?.id === session.id) report("done");
53
+ } catch (error) {
54
+ if (live?.id !== session.id) return;
55
+ session.error = error instanceof Error ? error : new Error(String(error));
56
+ report(session.controller.signal.aborted ? "cancelled" : "error");
57
+ }
58
+ })();
59
+ return true;
60
+ };
61
+ const cancelAi = () => {
62
+ if (!live || live.status !== "streaming") return false;
63
+ live.controller.abort();
64
+ report("cancelled");
65
+ return true;
66
+ };
67
+ const acceptAi = () => {
68
+ if (!live) return false;
69
+ live = null;
70
+ options.onStatus?.(snapshot());
71
+ return true;
72
+ };
73
+ const rejectAi = (ctx) => {
74
+ if (!live) return false;
75
+ live.controller.abort();
76
+ const target = live.marker.mapRange(live.captured);
77
+ live = null;
78
+ options.onStatus?.(snapshot());
79
+ return ctx.select(target);
80
+ };
81
+ return {
82
+ kind: "extension",
83
+ name: "ai",
84
+ commands: { askAi, cancelAi, acceptAi, rejectAi },
85
+ onCreate(editor) {
86
+ editorRef = editor;
87
+ },
88
+ onDestroy() {
89
+ live?.controller.abort();
90
+ live = null;
91
+ editorRef = null;
92
+ }
93
+ };
94
+ }
95
+ function selectedText(ctx) {
96
+ const { from, to } = ctx.selection;
97
+ const parts = [];
98
+ let offset = 0;
99
+ const walk = (node) => {
100
+ if (typeof node.text === "string") {
101
+ const start = offset;
102
+ const end = offset + node.text.length;
103
+ if (end > from && start < to) {
104
+ parts.push(node.text.slice(Math.max(0, from - start), Math.max(0, to - start)));
105
+ }
106
+ offset = end;
107
+ return;
108
+ }
109
+ offset += 1;
110
+ for (const child of node.content ?? []) {
111
+ walk(child);
112
+ }
113
+ offset += 1;
114
+ };
115
+ walk(ctx.doc);
116
+ return parts.join("");
117
+ }
118
+
119
+ export { ai };
120
+ //# sourceMappingURL=index.js.map
121
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/extension.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { Command, Ctx, Editor, ExtensionDef, PosMarker, Range } from '@matrajs/core'\nimport type { AiRequest, AiSession, AiStatus, AiStream } from './types'\n\nexport interface AiOptions {\n /** Where the text comes from. Keep the model key on your server, not here. */\n stream: AiStream\n /** Called on every status change, for spinners and error toasts. */\n onStatus?: (session: AiSession) => void\n}\n\ninterface Live {\n id: number\n marker: PosMarker\n /** The range as captured when the request started. */\n captured: Range\n received: string\n status: AiStatus\n controller: AbortController\n error?: Error\n}\n\n/**\n * Streaming AI edits that survive concurrent typing.\n *\n * The whole point of this extension is the marker. A request captures the range\n * it is rewriting *and* a position marker; every chunk that comes back is\n * applied to `marker.mapRange(captured)`, not to the original numbers. The user\n * can type, delete and paste anywhere while the model is talking, and the\n * answer still lands on the words they selected.\n */\nexport function ai(options: AiOptions): ExtensionDef<{\n askAi: Command<[instruction: string]>\n cancelAi: Command\n acceptAi: Command\n rejectAi: Command\n}> {\n let live: Live | null = null\n let nextId = 1\n let editorRef: Editor | null = null\n\n const snapshot = (): AiSession => ({\n id: live?.id ?? 0,\n status: live?.status ?? 'idle',\n range: live ? live.marker.mapRange(live.captured) : { from: 0 as never, to: 0 as never },\n received: live?.received ?? '',\n error: live?.error,\n })\n\n const report = (status: AiStatus) => {\n if (live) live.status = status\n options.onStatus?.(snapshot())\n }\n\n /** Replace the live range with whatever has arrived so far. */\n const applyChunk = (editor: Editor, session: Live) => {\n const target = session.marker.mapRange(session.captured)\n editor.commands.replace(target, session.received)\n }\n\n const askAi: Command<[string]> = (ctx, instruction) => {\n if (live && live.status === 'streaming') return false\n const editor = editorRef\n if (!editor) return false\n\n const { from, to } = ctx.selection\n if (from === to) return false\n\n const selected = selectedText(ctx)\n if (!selected) return false\n\n const session: Live = {\n id: nextId++,\n marker: ctx.mark(),\n captured: { from, to },\n received: '',\n status: 'streaming',\n controller: new AbortController(),\n }\n live = session\n report('streaming')\n\n void (async () => {\n const request: AiRequest = {\n text: selected,\n instruction,\n signal: session.controller.signal,\n }\n try {\n for await (const chunk of options.stream(request)) {\n if (session.controller.signal.aborted || live?.id !== session.id) return\n session.received += chunk\n applyChunk(editor, session)\n report('streaming')\n }\n if (live?.id === session.id) report('done')\n } catch (error) {\n if (live?.id !== session.id) return\n session.error = error instanceof Error ? error : new Error(String(error))\n report(session.controller.signal.aborted ? 'cancelled' : 'error')\n }\n })()\n\n return true\n }\n\n const cancelAi: Command = () => {\n if (!live || live.status !== 'streaming') return false\n live.controller.abort()\n report('cancelled')\n return true\n }\n\n const acceptAi: Command = () => {\n if (!live) return false\n live = null\n options.onStatus?.(snapshot())\n return true\n }\n\n /** Put back what was there before the model started talking. */\n const rejectAi: Command = (ctx) => {\n if (!live) return false\n live.controller.abort()\n const target = live.marker.mapRange(live.captured)\n live = null\n options.onStatus?.(snapshot())\n // The original text is recovered by undoing the streamed replacements.\n return ctx.select(target)\n }\n\n return {\n kind: 'extension',\n name: 'ai',\n commands: { askAi, cancelAi, acceptAi, rejectAi },\n onCreate(editor) {\n editorRef = editor as Editor\n },\n onDestroy() {\n live?.controller.abort()\n live = null\n editorRef = null\n },\n }\n}\n\n/** The plain text inside the current selection. */\nfunction selectedText(ctx: Ctx): string {\n const { from, to } = ctx.selection\n const parts: string[] = []\n let offset = 0\n\n const walk = (node: { text?: string; content?: unknown[] }) => {\n if (typeof node.text === 'string') {\n const start = offset\n const end = offset + node.text.length\n if (end > from && start < to) {\n parts.push(node.text.slice(Math.max(0, from - start), Math.max(0, to - start)))\n }\n offset = end\n return\n }\n offset += 1\n for (const child of (node.content ?? []) as { text?: string; content?: unknown[] }[]) {\n walk(child)\n }\n offset += 1\n }\n\n walk(ctx.doc as { text?: string; content?: unknown[] })\n return parts.join('')\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@matrajs/ai",
3
+ "version": "0.0.1",
4
+ "description": "AI extension for Matra \u2014 streaming edits that survive concurrent typing.",
5
+ "license": "MIT",
6
+ "author": "Nahim Hossain Shohan",
7
+ "homepage": "https://matrajs.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/amrelaco/matra.git",
11
+ "directory": "packages/ai"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/amrelaco/matra/issues"
15
+ },
16
+ "keywords": [
17
+ "editor",
18
+ "rich-text",
19
+ "ai",
20
+ "streaming",
21
+ "matra",
22
+ "prosemirror"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "main": "./dist/index.cjs",
30
+ "module": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js",
36
+ "require": "./dist/index.cjs"
37
+ }
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "README.md"
42
+ ],
43
+ "scripts": {
44
+ "build": "tsup",
45
+ "clean": "rm -rf dist"
46
+ },
47
+ "dependencies": {
48
+ "@matrajs/core": "workspace:*"
49
+ }
50
+ }