Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/chatbook-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ code) is configured in Settings → Chatbook; see [`chatbook.md`](chatbook.md).

Subclass `ChatbookMentionProvider` to add a browsable root. List item values are
provider-local opaque strings; NBI namespaces them as
`@ext:<provider-id>:<value>`.
`@ext:<provider-id>:<value>`, or `@ext:"<provider-id>:<value>"` when the value
contains whitespace or `@`. `resolve_mention` receives the value exactly as written:
unlike file and folder mentions, trailing punctuation is not trimmed, because
only the provider knows whether it is part of the value.

```python
from notebook_intelligence import (
Expand Down
4 changes: 4 additions & 0 deletions docs/chatbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ Chatbook adds six commands, all reachable from the command palette:

Typing `@` in a prompt cell opens a menu of context to attach. The built-in **Files & folders** provider browses the Jupyter root, skipping dotfiles, `__pycache__`, and `node_modules`; a selected file is read and truncated at 16,000 characters. Extensions can register providers of their own, which appear in the same menu under their own root (see [`chatbook-extensions.md`](chatbook-extensions.md)).

A mention ends at the first whitespace or `@`, so a path that contains either is written in double quotes, `@file:"data/my notes.md"`; the menu inserts that form for you. A quoted path is taken exactly as written and cannot contain a double quote or a line break, so a name with a double quote can only be mentioned when it needs no quoting, and a name that itself begins and ends with a double quote cannot be mentioned at all.

Sentence punctuation typed right after an unquoted file or folder mention (`.` `,` `;` `:` `!` `?` and `…`, closing brackets and quotes, and their full-width forms) is not part of the path, so `Summarize @file:data/README.md, then chart it` refers to `data/README.md`. The name as written is tried first, then with up to five of those characters removed one at a time, and the first name that exists is used even if it cannot be read. So a file whose real name ends in punctuation still resolves, and a shorter name is never reached past one that exists. Other characters, such as `+` or `-`, are always part of the path.

Mentions work only in prompt cells. A prompt that carries one is regenerated every run rather than reusing the previously approved code, because the mentioned content may have changed since.

## Export as a code notebook
Expand Down
156 changes: 111 additions & 45 deletions notebook_intelligence/chatbook_mentions.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,26 @@
MAX_DIRECTORY_ITEMS = 100
BUILTIN_SKIPPED_DIRECTORIES = frozenset({"__pycache__", "node_modules"})

MENTION_TOKEN_RE = re.compile(r"(?<![\w@])@([^\s@]+)")
# A mention is either quoted, `@file:"data/my notes.md"`, which is how a path
# containing whitespace or `@` is written, or unquoted and runs to the next
# whitespace. The quoted form follows Claude Code (`@"path"`) and Codex, which
# quotes a picked path containing whitespace. NUI's mention parser
# (plmbr/nui internal/mentions) is meant to accept the same tokens (#503), so
# change the two together.
MENTION_TOKEN_RE = re.compile(
r'(?<![\w@])@(?:(file|dir|ext):"([^"\n\r\x85\u2028\u2029]+)"(?![\w"])|([^\s@]+))'
)
# Sentence punctuation typed after an unquoted file or dir mention
# (`see @file:notes.md,`) is not part of the path. This follows Claude Code,
# but only for sentence punctuation: `+` or `-` stay part of the name. The
# characters are removed one at a time, at most a few, stopping at the first
# candidate that exists, so a name that really ends in one of them still
# resolves and a shorter, unrelated name is never reached past it.
TRAILING_PUNCTUATION = frozenset(
".,;:!?)]}'\"\u2026\u201d\u2019\u00bb"
"\u3002\u3001\uff0c\uff1b\uff1a\uff01\uff1f\uff09\u300d\u300f"
)
MAX_TRAILING_PUNCTUATION = 5


def _root_path() -> Path:
Expand Down Expand Up @@ -217,26 +236,72 @@ def list_chatbook_mentions(
)


def parse_chatbook_mentions(prompt: str) -> list[tuple[str, str]]:
"""Return unique (kind, relative path) mentions, preserving prompt order."""
result: list[tuple[str, str]] = []
seen: set[tuple[str, str]] = set()
def _parse_mention_tokens(prompt: str) -> list[tuple[str, str, bool, str]]:
"""Return (kind, value, quoted, token as written), once each, in order."""
result: list[tuple[str, str, bool, str]] = []
seen: set[tuple[str, str, bool]] = set()
for match in MENTION_TOKEN_RE.finditer(prompt or ""):
value = match.group(1)
if value.startswith("file:"):
item = ("file", value.removeprefix("file:"))
elif value.startswith("dir:"):
item = ("dir", value.removeprefix("dir:"))
elif value.startswith(EXTENSION_ROOT_PREFIX):
item = ("ext", value.removeprefix(EXTENSION_ROOT_PREFIX))
if match.group(1):
kind, value, quoted = match.group(1), match.group(2), True
else:
continue
if item not in seen:
seen.add(item)
result.append(item)
raw = match.group(3)
kind, separator, value = raw.partition(":")
if kind not in ("file", "dir", "ext") or not separator:
continue
quoted = False
if (kind, value, quoted) not in seen:
seen.add((kind, value, quoted))
result.append((kind, value, quoted, match.group(0)))
return result


def _path_candidates(value: str, quoted: bool) -> list[str]:
"""The path as written, then with trailing punctuation removed one by one."""
candidates = [value]
if quoted:
return candidates
while (
len(candidates) <= MAX_TRAILING_PUNCTUATION
and len(candidates[-1]) > 1
and candidates[-1][-1] in TRAILING_PUNCTUATION
):
candidates.append(candidates[-1][:-1])
return candidates


def _locate_mention_path(
kind: str,
value: str,
quoted: bool,
root: Path,
skipped_directories: Iterable[str],
) -> tuple[Path | None, tuple[str, str]]:
"""Find the path a file or dir mention names, and the key to dedupe it on.

Returns the first candidate that exists, even if it later cannot be read.
A refused candidate ends the search, so trimming never steps past it.
"""
candidates = _path_candidates(value, quoted)
for candidate in candidates:
try:
path = _safe_relative_path(candidate, skipped_directories)
if path.exists():
return path, (kind, _relative_display(path, root))
except (OSError, RuntimeError, UnicodeError, ValueError):
return None, (kind, candidate)
return None, (kind, candidates[-1])


def parse_chatbook_mentions(prompt: str) -> list[tuple[str, str]]:
"""Return (kind, value as written) for each mention, in prompt order.

A value is not yet a path: trailing punctuation is trimmed only when the
mention is resolved. The same text written quoted and unquoted appears
twice, because the two can resolve differently.
"""
return [(kind, value) for kind, value, _, _ in _parse_mention_tokens(prompt)]


def resolve_chatbook_mentions(
prompt: str,
skipped_directories: Iterable[str] = (),
Expand All @@ -248,17 +313,21 @@ def resolve_chatbook_mentions(
context_hash: str = "",
) -> list[dict[str, str]]:
"""Resolve mention tokens into bounded, soft-failing reference context."""
mentions = parse_chatbook_mentions(prompt)
mentions = _parse_mention_tokens(prompt)
if not mentions:
return []
root = _root_path()
provider_by_id = {provider.id: provider for provider in providers}
current = (notebook_context or {}).get("current") or {}
cell_index = current.get("index") if isinstance(current, dict) else None
resolved: list[dict[str, str]] = []
for kind, relative in mentions:
token = f"@{kind}:{relative}"
resolved_keys: set[tuple[str, str]] = set()
for kind, relative, quoted, token in mentions:
if kind == "ext":
# Quoted and unquoted spellings name the same provider value.
if ("ext", relative) in resolved_keys:
continue
resolved_keys.add(("ext", relative))
provider_id, value = _split_extension_mention(relative)
provider = provider_by_id.get(provider_id)
try:
Expand Down Expand Up @@ -302,32 +371,29 @@ def resolve_chatbook_mentions(
}
)
continue
try:
path = _safe_relative_path(relative, skipped_directories)
display = _relative_display(path, root)
if kind == "file":
content = _read_text_file(path)
else:
content = _list_directory(path)
resolved.append(
{
"token": token,
"kind": kind,
"path": display,
"content": content,
"available": "true",
}
)
except (OSError, RuntimeError, UnicodeError, ValueError):
resolved.append(
{
"token": token,
"kind": kind,
"path": relative,
"content": "[unavailable]",
"available": "false",
}
)
path, key = _locate_mention_path(
kind, relative, quoted, root, skipped_directories
)
if key in resolved_keys:
continue
resolved_keys.add(key)
entry = {
"token": token,
"kind": kind,
"path": key[1] if path is not None else relative,
"content": "[unavailable]",
"available": "false",
}
if path is not None:
try:
if kind == "file":
content = _read_text_file(path)
else:
content = _list_directory(path)
entry.update(content=content, available="true")
except (OSError, RuntimeError, UnicodeError, ValueError):
pass
resolved.append(entry)
return resolved


Expand Down
8 changes: 7 additions & 1 deletion src/chatbook-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ export const DEFAULT_CHATBOOK_EXECUTION_MODE: ChatbookExecutionMode =
export const DEFAULT_CHATBOOK_MAX_EXECUTION_MODE: ChatbookExecutionMode =
'auto-run';

/** Same token rule as `MENTION_TOKEN_RE` in chatbook_mentions.py. */
/**
* Detects whether a prompt carries a mention, the way `MENTION_TOKEN_RE` in
* chatbook_mentions.py does. A quoted mention always also matches this unquoted
* form, so presence needs nothing more. JS `\w` is ASCII-only, so after a
* non-ASCII letter this can see a mention the server does not, which only costs
* a regeneration.
*/
export const CHATBOOK_MENTION_TOKEN_RE = /(?<![\w@])@[^\s@]+/u;

export function promptHasChatbookMention(prompt: string): boolean {
Expand Down
67 changes: 49 additions & 18 deletions src/chatbook-mentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,55 @@ export function detectChatbookMentionTrigger(
};
}

/**
* Whether an unquoted token would end partway through `text`: at whitespace,
* at `@`, or at U+001C to U+001F, which Python's `\s` also counts.
*/
function endsUnquotedToken(text: string): boolean {
return (
/[\s@]/u.test(text) ||
[...text].some(ch => ch >= '\u001c' && ch <= '\u001f')
);
}

/**
* The token to insert for a mention value, quoted as `@file:"my notes.md"`
* when an unquoted token would cut it short, the way Codex quotes a picked
* path. The quoted form cannot hold a quote or a line break, so such a value
* is left as is.
*/
export function chatbookMentionToken(value: string): string {
const match = /^(file|dir|ext):(.*)$/su.exec(value);
if (
!match ||
!endsUnquotedToken(match[2]) ||
/["\n\r\u0085\u2028\u2029]/u.test(match[2])
) {
return `@${value}`;
}
return `@${match[1]}:"${match[2]}"`;
}

/** The text a picked mention inserts before `suffix`, the text after it. */
export function chatbookMentionInsertText(
value: string,
suffix: string
): string {
// Avoid doubling a space when completion is accepted before existing
// whitespace in the middle of a sentence.
return `${chatbookMentionToken(value)}${/^\s/u.test(suffix) ? '' : ' '}`;
}

export function applyChatbookMention(
text: string,
trigger: IChatbookMentionTrigger,
value: string
): string {
const suffix = text.slice(trigger.to);
const spacer = /^\s/u.test(suffix) ? '' : ' ';
return `${text.slice(0, trigger.from)}@${value}${spacer}${suffix}`;
return `${text.slice(0, trigger.from)}${chatbookMentionInsertText(
value,
suffix
)}${suffix}`;
}

const MENU_KEYS = new Set([
Expand Down Expand Up @@ -245,23 +286,13 @@ class ChatbookMentionMenu {
if (!trigger) {
return;
}
const insert = chatbookMentionInsertText(
item.value,
this.view.state.doc.sliceString(trigger.to)
);
const transaction: TransactionSpec = {
// Avoid doubling a space when completion is accepted before existing
// whitespace in the middle of a sentence.
changes: {
from: trigger.from,
to: trigger.to,
insert: `@${item.value}${
/^\s/u.test(this.view.state.doc.sliceString(trigger.to)) ? '' : ' '
}`
},
selection: {
anchor:
trigger.from +
item.value.length +
1 +
(/^\s/u.test(this.view.state.doc.sliceString(trigger.to)) ? 0 : 1)
},
changes: { from: trigger.from, to: trigger.to, insert },
selection: { anchor: trigger.from + insert.length },
scrollIntoView: true
};
this.view.dispatch(transaction);
Expand Down
Loading
Loading