Merge remote-tracking branch 'origin/feat/imanage'
This commit is contained in:
@@ -0,0 +1,262 @@
|
|||||||
|
# Hanzo AI for iManage
|
||||||
|
|
||||||
|
AI over your iManage Work matter — **summarize a document**, **extract key clauses,
|
||||||
|
dates & parties** (contract review), **search & synthesize** across a matter, and
|
||||||
|
**compare a document set**, plus a freeform "ask about this matter" box. An
|
||||||
|
embedded-app **panel** you host at `imanage.hanzo.ai`, backed by a small **OAuth +
|
||||||
|
Work-API-proxy service** that keeps the iManage client secret and access tokens
|
||||||
|
server-side.
|
||||||
|
|
||||||
|
iManage Work is the dominant document management system (DMS) in legal and
|
||||||
|
professional services. This app reads workspaces, folders, documents (metadata +
|
||||||
|
content), and search results through the iManage Work API and grounds every AI
|
||||||
|
answer in them.
|
||||||
|
|
||||||
|
Built on the published Hanzo SDK:
|
||||||
|
|
||||||
|
- **[`@hanzo/ai`](https://www.npmjs.com/package/@hanzo/ai)** — the headless client.
|
||||||
|
`createAiClient({ baseUrl, token }).chat.completions.create({ model, messages })`
|
||||||
|
and `.models.list()`. All model calls go to `https://api.hanzo.ai` on `/v1/...`
|
||||||
|
(never an `/api/` prefix). We import it; we do not reimplement the transport.
|
||||||
|
- **[`@hanzo/iam`](https://www.npmjs.com/package/@hanzo/iam)** — Hanzo identity /
|
||||||
|
the `hk-…` API key the panel uses as its gateway bearer.
|
||||||
|
|
||||||
|
> **Note on `/api/`.** The `/api/` ban is a **Hanzo gateway** rule (`api.hanzo.ai`
|
||||||
|
> IS the api host, so paths are `/v1/...`). iManage's **own** Work API path is
|
||||||
|
> `/api/v2/...` — that is iManage's host, and it is correct to use it verbatim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser panel (dist/index.html, app.js) Server service (dist/server.js)
|
||||||
|
───────────────────────────────────── ──────────────────────────────
|
||||||
|
reads iManage via ──► /proxy/* ──────────► injects the access token
|
||||||
|
same-origin proxy (cookie) (X-Auth-Token) + refresh, forwards
|
||||||
|
(never sees an iManage token) to {host}/api/v2/customers/…
|
||||||
|
│
|
||||||
|
└── calls api.hanzo.ai /v1 directly with the pasted hk-… Hanzo key
|
||||||
|
(@hanzo/ai headless client)
|
||||||
|
```
|
||||||
|
|
||||||
|
- The **panel** never holds an iManage token or the client secret. It reaches
|
||||||
|
iManage only through the same-origin `/proxy/*` endpoint (with an HttpOnly
|
||||||
|
session cookie). It talks to `api.hanzo.ai` directly with the user's pasted Hanzo
|
||||||
|
key. The customer + library scope lives in the request **path**
|
||||||
|
(`/customers/{cid}/libraries/{lib}/…`), so the proxy just prepends the Work API
|
||||||
|
base and forwards.
|
||||||
|
- The **service** is the only place `IMANAGE_CLIENT_SECRET` and the OAuth tokens
|
||||||
|
exist. It runs the install flow, holds the session, refreshes the token
|
||||||
|
transparently, and proxies Work API calls.
|
||||||
|
|
||||||
|
### Source layout (all pure logic is unit-tested)
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/config.ts` | Host resolution (normalize/validate a per-customer iManage host), the `/api/v2` Work API root, the gateway URL, server-secret config (`readServerConfig` fails fast). |
|
||||||
|
| `src/imanage-oauth.ts` | OAuth2 Authorization-Code shaping: `authorizeUrl`, `tokenExchange`, `refreshExchange`, token parsing + expiry math. Pure. |
|
||||||
|
| `src/imanage-api.ts` | Work API v2 request wrappers (`listWorkspaces`, `listFolderChildren`, `getDocument`, `getDocumentContent`, `search`, `updateDocumentProfile`) with customer/library scoping + offset/limit pagination, and envelope-aware response parsers. Pure. |
|
||||||
|
| `src/documents.ts` | Document profile + extracted content + workspace/folder/search JSON → windowed context text + honest truncation, and text extraction (`extractText`, binary/OCR out of scope). Pure. |
|
||||||
|
| `src/hanzo.ts` | Thin over `@hanzo/ai`: legal-grounded prompt assembly + the single `ask` path + `listModels`. |
|
||||||
|
| `src/actions.ts` | The four AI actions (id → prompt) over `ask`. One code path to the model. |
|
||||||
|
| `src/panel.ts` | Browser proxy-client request shaping + launch-context parsing. Pure. |
|
||||||
|
| `src/app.ts` | The DOM glue for the panel (the one impure browser entry). |
|
||||||
|
| `src/server.ts` | Node http service: OAuth install/callback + `/config` + `/proxy/*`. Never logs document content. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Register an app in the iManage Control Center
|
||||||
|
|
||||||
|
In the **iManage Control Center** for your customer:
|
||||||
|
|
||||||
|
1. Go to **Applications** → **Add** and register a new OAuth2 application. This
|
||||||
|
yields a **Client ID** and **Client Secret**.
|
||||||
|
2. Set the **grant type** to **Authorization Code** and add the **redirect URI** —
|
||||||
|
exactly the URL your service serves the callback at:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://imanage.hanzo.ai/oauth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
(For local development, add `http://localhost:8793/oauth/callback` too.)
|
||||||
|
3. Scope is optional for the Authorization-Code app — access is governed by the
|
||||||
|
signed-in user's own Work permissions. (If your Control Center offers scopes,
|
||||||
|
grant read access to documents/workspaces and, for the optional write-back,
|
||||||
|
document profile update.)
|
||||||
|
4. Note your **iManage host**, your **customer id**, and the **library id(s)** you
|
||||||
|
work in (e.g. `ACTIVE_US`). The host is per-customer:
|
||||||
|
|
||||||
|
| Deployment | Host |
|
||||||
|
| --- | --- |
|
||||||
|
| iManage Cloud (US) | `https://cloudimanage.com` |
|
||||||
|
| Regional cloud | `https://<region>.imanage.work` |
|
||||||
|
| On-prem Work server | `https://work.<firm>.com` |
|
||||||
|
|
||||||
|
The OAuth control-center endpoints and the Work API are served from this host
|
||||||
|
(set `IMANAGE_API_HOST` if your Work API is fronted separately).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. OAuth flow
|
||||||
|
|
||||||
|
Standard **Authorization Code** grant (server-side secret):
|
||||||
|
|
||||||
|
1. `GET /oauth/install` → 302 to
|
||||||
|
`https://{host}/auth/oauth2/authorize?response_type=code&client_id=…&redirect_uri=…&state=…`.
|
||||||
|
2. The user signs in and consents; iManage redirects back to
|
||||||
|
`GET /oauth/callback?code=…&state=…`.
|
||||||
|
3. The service verifies `state`, then POSTs to `/auth/oauth2/token` with
|
||||||
|
`grant_type=authorization_code`, the `code`, the `client_id` + `client_secret`
|
||||||
|
(**in the body, server-side only**), and the same `redirect_uri`.
|
||||||
|
4. It opens a session, sets an HttpOnly cookie, and the panel is authenticated.
|
||||||
|
5. Access tokens are short-lived. The service refreshes with
|
||||||
|
`grant_type=refresh_token` **before** each proxied call when the token has
|
||||||
|
expired, storing whatever refresh token comes back.
|
||||||
|
|
||||||
|
> In-memory sessions here are for a single instance. For a multi-instance
|
||||||
|
> deployment behind `hanzoai/ingress`, persist sessions + the OAuth `state` set to
|
||||||
|
> `hanzoai/kv` (Valkey), and read `IMANAGE_CLIENT_SECRET` from KMS
|
||||||
|
> (`kms.hanzo.ai`) — never from a committed env file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. iManage Work API
|
||||||
|
|
||||||
|
All reads/writes go through `https://{host}/api/v2/...`, scoped by customer +
|
||||||
|
library in the path. The access token rides in the **`X-Auth-Token`** header
|
||||||
|
(iManage's Work API convention).
|
||||||
|
|
||||||
|
| Capability | Endpoint (relative to `/api/v2`) |
|
||||||
|
| --- | --- |
|
||||||
|
| Libraries | `GET /customers/{cid}/libraries` |
|
||||||
|
| Workspaces | `GET /customers/{cid}/libraries/{lib}/workspaces` |
|
||||||
|
| Workspace (detail) | `GET …/workspaces/{id}` |
|
||||||
|
| Folder / workspace contents | `GET …/folders/{folderId}/children` (folders + documents) |
|
||||||
|
| Document (profile) | `GET …/documents/{id}` |
|
||||||
|
| Document content | `GET …/documents/{id}/download` |
|
||||||
|
| Search | `GET …/documents/search?q=…` |
|
||||||
|
| **Profile write-back** (optional) | `PATCH …/documents/{id}` — body `{ "data": { "comment": "…" } }` |
|
||||||
|
|
||||||
|
Responses are wrapped in an iManage `{ "data": … }` envelope; list/search endpoints
|
||||||
|
paginate with **`offset` + `limit`** and report totals/overflow in the body
|
||||||
|
(surfaced by `parsePaging`). All of this is normalized at the parser boundary.
|
||||||
|
|
||||||
|
### Document content & text extraction
|
||||||
|
|
||||||
|
`getDocumentContent` fetches `…/documents/{id}/download`. This app assumes **text or
|
||||||
|
server-exported text**; `extractText` cleans it for the model. **Binary formats**
|
||||||
|
(native `.docx`/`.pdf`, images) and **OCR are out of scope** — such content is
|
||||||
|
reported honestly (`extracted: false` with a reason) and the actions summarize from
|
||||||
|
the **profile metadata** alone rather than feeding the model garbage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The summarize-document / extract-clauses flow
|
||||||
|
|
||||||
|
1. Connect the app (`/oauth/install`) so the service holds an iManage token.
|
||||||
|
2. Open the panel, enter your **customer id** + **library id** (or arrive
|
||||||
|
pre-scoped via the launch-context query / a single-tenant `/config`), and
|
||||||
|
**Browse** a workspace. Pick a document and **Load doc** — the panel pulls the
|
||||||
|
document's profile + content through the proxy and assembles a windowed,
|
||||||
|
truncation-honest context.
|
||||||
|
3. **Summarize document** — the model summarizes the document type, purpose,
|
||||||
|
parties, key terms, and anything needing attention, grounded only in the text
|
||||||
|
(or the profile, when content isn't extractable).
|
||||||
|
4. **Key clauses, dates & parties** — a contract-review extraction returning four
|
||||||
|
grounded lists: **Parties**, **Key dates**, **Key clauses** (governing law,
|
||||||
|
indemnity, limitation of liability, confidentiality, termination — quoting the
|
||||||
|
operative language), and **Dollar amounts**. Anything not written is reported as
|
||||||
|
"None found in the text" — never inferred.
|
||||||
|
5. **Search & synthesize** — enter a query, **Search** the library, and run the
|
||||||
|
action to synthesize across the result set, attributing each point to its
|
||||||
|
document.
|
||||||
|
6. **Compare document set** — over a search result set (or a matter), lists the
|
||||||
|
material differences side by side.
|
||||||
|
|
||||||
|
### Optional write-back (gated + documented)
|
||||||
|
|
||||||
|
**Save to comment** writes the current result into the loaded document's **`comment`
|
||||||
|
profile field** via `PATCH …/documents/{id}` — behind an explicit button **and** a
|
||||||
|
confirm dialog, targeting only the single loaded document (disabled for a search
|
||||||
|
result set). It never touches document content. This is the only write path; leave
|
||||||
|
it unused for a strictly read-only deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Confidentiality posture (legal)
|
||||||
|
|
||||||
|
This app handles **confidential, potentially privileged** legal documents. The
|
||||||
|
design keeps that boundary tight:
|
||||||
|
|
||||||
|
- **Tokens + secret are server-side only.** The browser never receives the iManage
|
||||||
|
access token or the client secret; it reaches iManage exclusively through the
|
||||||
|
same-origin proxy with an HttpOnly session cookie.
|
||||||
|
- **No document content is ever logged.** The proxy logs only non-content
|
||||||
|
bookkeeping (a session id, a status, a target path) — never request/response
|
||||||
|
bodies, document text, profiles, or search terms.
|
||||||
|
- **The model is told the material is confidential** and instructed to reveal no
|
||||||
|
more of the text than the task requires, to never invent clauses/dates/parties,
|
||||||
|
and that its output is **informational document review, not legal advice** (no
|
||||||
|
attorney-client relationship).
|
||||||
|
- **Grounded, truncation-honest context.** Answers are built only from the windowed
|
||||||
|
records actually sent; when the budget truncates, the context note says so and the
|
||||||
|
model is told to flag it rather than guess.
|
||||||
|
- **Least privilege.** Read-and-assist is the default; the one write path is gated
|
||||||
|
behind an explicit action + confirm and updates only a profile comment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter @hanzo/imanage build # → dist/ (panel + server)
|
||||||
|
pnpm --filter @hanzo/imanage test # vitest
|
||||||
|
pnpm --filter @hanzo/imanage typecheck # tsc --noEmit
|
||||||
|
|
||||||
|
# run the service
|
||||||
|
IMANAGE_CLIENT_ID=… \
|
||||||
|
IMANAGE_CLIENT_SECRET=… \
|
||||||
|
IMANAGE_REDIRECT_URI=https://imanage.hanzo.ai/oauth/callback \
|
||||||
|
IMANAGE_HOST=https://cloudimanage.com \
|
||||||
|
node packages/imanage/dist/server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required / optional environment (service)
|
||||||
|
|
||||||
|
| Var | Notes |
|
||||||
|
| --- | --- |
|
||||||
|
| `IMANAGE_CLIENT_ID` | App client id (public). |
|
||||||
|
| `IMANAGE_CLIENT_SECRET` | **Server only.** From KMS in production. |
|
||||||
|
| `IMANAGE_REDIRECT_URI` | Must match the app's registered redirect. |
|
||||||
|
| `IMANAGE_HOST` | The customer's iManage host (auth + Work API). |
|
||||||
|
| `IMANAGE_API_HOST` | Optional — a distinct Work API host (defaults to `IMANAGE_HOST`). |
|
||||||
|
| `IMANAGE_CUSTOMER_ID` | Optional — panel scoping default (served at `/config`). |
|
||||||
|
| `IMANAGE_LIBRARY_ID` | Optional — panel scoping default (served at `/config`). |
|
||||||
|
| `PORT` | Listen port (default `8793`). |
|
||||||
|
|
||||||
|
`readServerConfig` throws on a missing secret or a malformed host — the service
|
||||||
|
refuses to start rather than pretend it can complete an OAuth exchange.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploy over hanzoai/ingress
|
||||||
|
|
||||||
|
Host the static panel (`dist/index.html`, `app.js`, `styles.css`) with the
|
||||||
|
**hanzoai/static** plugin and run `dist/server.js` as a small service, both behind
|
||||||
|
**hanzoai/ingress** at `imanage.hanzo.ai` (no nginx, no caddy):
|
||||||
|
|
||||||
|
- `/` and the static assets → the panel.
|
||||||
|
- `/oauth/*`, `/config`, `/proxy/*`, `/healthz` → the service.
|
||||||
|
|
||||||
|
Secrets come from **KMS** as `KMSSecret`-synced env; sessions from **Valkey**
|
||||||
|
(`hanzoai/kv`) for multi-instance. Image published to
|
||||||
|
`ghcr.io/hanzoai/imanage:<tag>` by CI/CD (platform.hanzo.ai / Tekton) — never built
|
||||||
|
locally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Routed through `api.hanzo.ai`. Answers are grounded in your iManage document
|
||||||
|
records — informational document review support, not legal advice, and no
|
||||||
|
attorney-client relationship is created.*
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Build @hanzo/imanage into dist/: bundle the web panel (app.ts) as ESM for the
|
||||||
|
// browser, copy index.html (with its entry script stamped) + styles.css, and
|
||||||
|
// bundle the OAuth + Work-API-proxy server for Node. No framework — esbuild + Node
|
||||||
|
// stdlib only. @hanzo/ai is bundled into both outputs (it is the headless client
|
||||||
|
// the panel and server both call).
|
||||||
|
//
|
||||||
|
// node build.js → production build
|
||||||
|
// node build.js --watch → rebuild on change
|
||||||
|
// HANZO_IMANAGE_BASE=https://localhost:8443 node build.js → dev base (informational)
|
||||||
|
//
|
||||||
|
// Output layout:
|
||||||
|
// dist/index.html dist/app.js dist/styles.css (panel)
|
||||||
|
// dist/server.js (service)
|
||||||
|
//
|
||||||
|
// The panel is HOSTED over hanzoai/static behind hanzoai/ingress at
|
||||||
|
// imanage.hanzo.ai; the server runs as a small service (OAuth callback + Work API
|
||||||
|
// proxy). All model calls go to api.hanzo.ai regardless of the host base.
|
||||||
|
|
||||||
|
import esbuild from 'esbuild';
|
||||||
|
import { rmSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const BASE = (process.env.HANZO_IMANAGE_BASE || 'https://imanage.hanzo.ai').replace(/\/+$/, '');
|
||||||
|
const watch = process.argv.includes('--watch');
|
||||||
|
const src = join(__dirname, 'src');
|
||||||
|
const dist = join(__dirname, 'dist');
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
rmSync(dist, { recursive: true, force: true });
|
||||||
|
mkdirSync(dist, { recursive: true });
|
||||||
|
|
||||||
|
// Bundle the panel as ESM for the browser.
|
||||||
|
const panelCtx = await esbuild.context({
|
||||||
|
entryPoints: { app: join(src, 'app.ts') },
|
||||||
|
outdir: dist,
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'browser',
|
||||||
|
target: ['chrome90', 'edge90', 'firefox90', 'safari15'],
|
||||||
|
sourcemap: true,
|
||||||
|
minify: !watch,
|
||||||
|
logLevel: 'info',
|
||||||
|
});
|
||||||
|
await panelCtx.rebuild();
|
||||||
|
|
||||||
|
// Copy index.html (stamp __ENTRY__ + base) and styles.css.
|
||||||
|
const html = readFileSync(join(src, 'index.html'), 'utf8')
|
||||||
|
.split('__ENTRY__').join('app.js')
|
||||||
|
.replace('</head>', ` <meta name="hanzo:base" content="${BASE}" />\n</head>`);
|
||||||
|
writeFileSync(join(dist, 'index.html'), html);
|
||||||
|
copyFileSync(join(src, 'styles.css'), join(dist, 'styles.css'));
|
||||||
|
|
||||||
|
// Bundle the server as a Node ESM binary — our pure stdlib code + @hanzo/ai.
|
||||||
|
const serverCtx = await esbuild.context({
|
||||||
|
entryPoints: [join(src, 'server.ts')],
|
||||||
|
outfile: join(dist, 'server.js'),
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'node',
|
||||||
|
target: ['node18'],
|
||||||
|
sourcemap: true,
|
||||||
|
minify: !watch,
|
||||||
|
banner: { js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);" },
|
||||||
|
logLevel: 'info',
|
||||||
|
});
|
||||||
|
await serverCtx.rebuild();
|
||||||
|
|
||||||
|
console.log(`Hanzo AI for iManage built -> dist/ (base ${BASE})`);
|
||||||
|
console.log(' Panel: dist/index.html · Service: dist/server.js');
|
||||||
|
|
||||||
|
if (watch) {
|
||||||
|
await panelCtx.watch();
|
||||||
|
await serverCtx.watch();
|
||||||
|
console.log('watching...');
|
||||||
|
} else {
|
||||||
|
await panelCtx.dispose();
|
||||||
|
await serverCtx.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "@hanzo/imanage",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Hanzo AI for iManage Work — AI over your legal document management system: summarize a document, extract key clauses / dates / parties, search-and-synthesize a matter/workspace, and compare a document set. An embedded-app panel plus an OAuth + Work-API-proxy service, built on @hanzo/ai and @hanzo/iam over the api.hanzo.ai gateway.",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.js",
|
||||||
|
"watch": "node build.js --watch",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hanzo/ai": "^0.2.0",
|
||||||
|
"@hanzo/iam": "^0.13.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"esbuild": "^0.25.8",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^3.2.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"hanzo",
|
||||||
|
"imanage",
|
||||||
|
"legal",
|
||||||
|
"dms",
|
||||||
|
"document-management",
|
||||||
|
"contract-review",
|
||||||
|
"ai",
|
||||||
|
"oauth"
|
||||||
|
],
|
||||||
|
"author": "Hanzo AI",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/hanzoai/extension.git",
|
||||||
|
"directory": "packages/imanage"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// The AI actions over an iManage document / matter — summarize a document, extract
|
||||||
|
// key clauses / dates / parties (contract review), search-and-synthesize a matter,
|
||||||
|
// and compare a document set. Each is a prompt template applied to the windowed
|
||||||
|
// document context via the single `ask` primitive in hanzo.ts. There is exactly
|
||||||
|
// ONE code path to the model: an action is (id → prompt), and the panel and any
|
||||||
|
// server-side caller resolve an id here and call `ask`. No action speaks to the
|
||||||
|
// gateway directly. (A freeform "ask about the matter" is `ask` directly, not an
|
||||||
|
// action — it has no fixed prompt.)
|
||||||
|
|
||||||
|
import { ask, type AskOptions, type MatterMeta } from './hanzo.js';
|
||||||
|
import type { DocumentContext } from './documents.js';
|
||||||
|
|
||||||
|
// The action catalog. `id` is the stable key the panel's chips use; `label` is the
|
||||||
|
// button text; `prompt` is the instruction handed to the model as the task, over
|
||||||
|
// the fenced document records. Prompts are specific and output-shaped (bullets,
|
||||||
|
// structured lists) so results are consistent and parseable.
|
||||||
|
export const ACTIONS = {
|
||||||
|
summarizeDocument: {
|
||||||
|
label: 'Summarize document',
|
||||||
|
prompt:
|
||||||
|
'Summarize the document in these records for a lawyer skimming the matter. ' +
|
||||||
|
'Cover: what kind of document it is, its purpose, the parties, the key terms ' +
|
||||||
|
'or obligations, and anything unusual or that needs attention. Reference the ' +
|
||||||
|
'document name. Use short bullets. No preamble. Ground every statement in the ' +
|
||||||
|
'text; if the content was not extractable and you only have the profile, say ' +
|
||||||
|
'so and summarize from the metadata alone.',
|
||||||
|
},
|
||||||
|
extractClauses: {
|
||||||
|
label: 'Key clauses, dates & parties',
|
||||||
|
prompt:
|
||||||
|
'Perform a contract-review extraction over the document in these records. ' +
|
||||||
|
'Return four labelled lists, each grounded strictly in the text: "Parties" ' +
|
||||||
|
'(every named party and their role); "Key dates" (effective date, term, ' +
|
||||||
|
'renewal, termination, and any deadline, each with what it governs); "Key ' +
|
||||||
|
'clauses" (governing law, indemnity, limitation of liability, confidentiality, ' +
|
||||||
|
'assignment, termination, and any other material clause — quote the operative ' +
|
||||||
|
'language); and "Dollar amounts / figures" (fees, caps, penalties). If a list ' +
|
||||||
|
'has no entries in the text, write "None found in the text." Never infer a ' +
|
||||||
|
'term that is not written.',
|
||||||
|
},
|
||||||
|
synthesizeMatter: {
|
||||||
|
label: 'Search & synthesize',
|
||||||
|
prompt:
|
||||||
|
'You are given a set of documents (or search results) from one matter. ' +
|
||||||
|
'Synthesize an answer to the matter question or, if none is posed, a short ' +
|
||||||
|
'briefing across the set: what the documents collectively establish, where ' +
|
||||||
|
'they agree, where they conflict, and what is missing. Attribute each point to ' +
|
||||||
|
'the document it comes from by name or number. Base every statement on the ' +
|
||||||
|
'records; if the set is marked truncated, note that the synthesis covers only ' +
|
||||||
|
'the shown documents.',
|
||||||
|
},
|
||||||
|
compareDocuments: {
|
||||||
|
label: 'Compare document set',
|
||||||
|
prompt:
|
||||||
|
'Compare the documents in these records. Produce: a one-line statement of what ' +
|
||||||
|
'they have in common; then a table-style list of the material differences ' +
|
||||||
|
'(term, obligation, date, amount, or clause) with each document\'s position ' +
|
||||||
|
'side by side; then any provision present in one document but absent in ' +
|
||||||
|
'another. Reference each document by name or number. Compare only what the ' +
|
||||||
|
'text supports — do not assume a standard or a missing term.',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// An action id from the catalog.
|
||||||
|
export type ActionId = keyof typeof ACTIONS;
|
||||||
|
|
||||||
|
// isActionId narrows an arbitrary string to a known action id. Boundary guard —
|
||||||
|
// the panel and the server validate an inbound id here before running it.
|
||||||
|
export function isActionId(id: string): id is ActionId {
|
||||||
|
return Object.prototype.hasOwnProperty.call(ACTIONS, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionPrompt resolves an action id to its prompt template. Throws on an unknown
|
||||||
|
// id (a boundary error, surfaced to the caller) rather than silently running a
|
||||||
|
// default. Pure.
|
||||||
|
export function actionPrompt(id: string): string {
|
||||||
|
if (!isActionId(id)) throw new Error(`Unknown action: ${id}`);
|
||||||
|
return ACTIONS[id].prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionList is the ordered catalog for building the UI (chips) — id + label,
|
||||||
|
// derived from ACTIONS so the panel and the catalog can never drift. Pure.
|
||||||
|
export function actionList(): Array<{ id: ActionId; label: string }> {
|
||||||
|
return (Object.keys(ACTIONS) as ActionId[]).map((id) => ({ id, label: ACTIONS[id].label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAction is the single entry point every surface calls: resolve the action's
|
||||||
|
// prompt and run it over the windowed document context via `ask`. This is the one
|
||||||
|
// code path from an action id to the model. Async so an unknown id surfaces as a
|
||||||
|
// rejected promise (not a synchronous throw), giving callers ONE way to handle
|
||||||
|
// failure: `await`/`.catch`. A gateway error rejects via `ask`.
|
||||||
|
export async function runAction(
|
||||||
|
id: string,
|
||||||
|
ctx: DocumentContext,
|
||||||
|
meta?: MatterMeta,
|
||||||
|
opts: AskOptions = {},
|
||||||
|
): Promise<string> {
|
||||||
|
return ask(actionPrompt(id), ctx, meta, opts);
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
// The iManage panel glue: the embedded/linked-app page that opens with a customer
|
||||||
|
// + library (and optionally a workspace or document) in context and drives the AI
|
||||||
|
// actions over the matter's live records. All the logic-heavy work (action
|
||||||
|
// prompts, context windowing, chat shaping, proxy request shaping, text
|
||||||
|
// extraction, auth) lives in its own tested modules; this file binds them to the
|
||||||
|
// DOM. It is the one impure, browser-only entry point.
|
||||||
|
//
|
||||||
|
// Data flow: the panel reads iManage ONLY through the same-origin server proxy
|
||||||
|
// (createProxyClient), which holds the OAuth token server-side. It loads a
|
||||||
|
// document's profile + extracted content (or a search result set), assembles a
|
||||||
|
// windowed context (buildContext), and runs an action or a freeform question
|
||||||
|
// against api.hanzo.ai with the pasted Hanzo key. The optional profile write-back
|
||||||
|
// (save the summary into the document's comment) posts back through the same
|
||||||
|
// proxy, behind an explicit button + confirm.
|
||||||
|
|
||||||
|
import { actionList, isActionId, runAction } from './actions.js';
|
||||||
|
import { ask as askHanzo, listModels, type MatterMeta } from './hanzo.js';
|
||||||
|
import {
|
||||||
|
buildContext,
|
||||||
|
contextNote,
|
||||||
|
documentSection,
|
||||||
|
documentIndexSection,
|
||||||
|
workspaceSection,
|
||||||
|
searchResultSection,
|
||||||
|
type DocumentContext,
|
||||||
|
} from './documents.js';
|
||||||
|
import { DEFAULT_MODEL } from './config.js';
|
||||||
|
import { getApiKey, setApiKey, hasApiKey, bearer, validateKey } from './auth.js';
|
||||||
|
import { createProxyClient, parseLaunchContext, type ProxyClient } from './panel.js';
|
||||||
|
import type { Workspace, DocumentEntry } from './imanage-api.js';
|
||||||
|
|
||||||
|
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||||
|
|
||||||
|
let controller: AbortController | null = null;
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const launch = parseLaunchContext(window.location.search);
|
||||||
|
|
||||||
|
const customerEl = $<HTMLInputElement>('customer');
|
||||||
|
const libraryEl = $<HTMLInputElement>('library');
|
||||||
|
const workspaceEl = $<HTMLSelectElement>('workspace');
|
||||||
|
const browseBtn = $<HTMLButtonElement>('browse');
|
||||||
|
const documentEl = $<HTMLSelectElement>('document');
|
||||||
|
const loadBtn = $<HTMLButtonElement>('load');
|
||||||
|
const searchEl = $<HTMLInputElement>('search');
|
||||||
|
const searchBtn = $<HTMLButtonElement>('searchbtn');
|
||||||
|
const recordsEl = $('records');
|
||||||
|
const outputEl = $<HTMLTextAreaElement>('output');
|
||||||
|
const statusEl = $('status');
|
||||||
|
const modelEl = $<HTMLSelectElement>('model');
|
||||||
|
const chipRow = $('chips');
|
||||||
|
const runBtn = $<HTMLButtonElement>('run');
|
||||||
|
const stopBtn = $<HTMLButtonElement>('stop');
|
||||||
|
const promptEl = $<HTMLTextAreaElement>('prompt');
|
||||||
|
const apiKeyEl = $<HTMLInputElement>('apikey');
|
||||||
|
const saveKeyBtn = $<HTMLButtonElement>('savekey');
|
||||||
|
const authHint = $('authhint');
|
||||||
|
const saveBtn = $<HTMLButtonElement>('save');
|
||||||
|
|
||||||
|
customerEl.value = launch.customerId;
|
||||||
|
libraryEl.value = launch.libraryId;
|
||||||
|
apiKeyEl.value = getApiKey();
|
||||||
|
reflectAuth();
|
||||||
|
void populateModels();
|
||||||
|
void loadConfigDefaults();
|
||||||
|
|
||||||
|
// Loaded state: the context the actions run over, the matter meta, and the
|
||||||
|
// document we're scoped to (for the profile write-back). A search result set has
|
||||||
|
// no single document, so write-back is disabled then.
|
||||||
|
let ctx: DocumentContext | null = null;
|
||||||
|
let meta: MatterMeta = {};
|
||||||
|
let currentDocumentId = '';
|
||||||
|
|
||||||
|
// Action chips — derived from the catalog so UI and logic never drift.
|
||||||
|
for (const a of actionList()) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'chip';
|
||||||
|
b.textContent = a.label;
|
||||||
|
b.dataset.action = a.id;
|
||||||
|
b.onclick = () => void run(a.id);
|
||||||
|
chipRow.appendChild(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// client builds a proxy client scoped to the current customer + library. Rebuilt
|
||||||
|
// on demand so a scope change takes effect immediately.
|
||||||
|
function client(): ProxyClient {
|
||||||
|
return createProxyClient({ customerId: customerEl.value.trim(), libraryId: libraryEl.value.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
browseBtn.onclick = () => void browseWorkspaces();
|
||||||
|
workspaceEl.onchange = () => void browseDocuments();
|
||||||
|
documentEl.onchange = () => {
|
||||||
|
currentDocumentId = documentEl.value;
|
||||||
|
};
|
||||||
|
loadBtn.onclick = () => void loadDocument();
|
||||||
|
searchBtn.onclick = () => void runSearch();
|
||||||
|
runBtn.onclick = () => {
|
||||||
|
const prompt = promptEl.value.trim();
|
||||||
|
if (prompt) void ask(prompt);
|
||||||
|
};
|
||||||
|
stopBtn.onclick = () => controller?.abort();
|
||||||
|
saveBtn.onclick = () => void saveToProfile();
|
||||||
|
saveKeyBtn.onclick = async () => {
|
||||||
|
const key = apiKeyEl.value.trim();
|
||||||
|
setStatus('Checking key…');
|
||||||
|
try {
|
||||||
|
const models = await validateKey(key);
|
||||||
|
setApiKey(key);
|
||||||
|
fillModels(models);
|
||||||
|
reflectAuth();
|
||||||
|
setStatus('Key saved.', 'ok');
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatus(e?.message || 'Key rejected.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we launched already scoped, browse workspaces; if a workspace or document
|
||||||
|
// was named, drill straight in.
|
||||||
|
if (launch.customerId && launch.libraryId) {
|
||||||
|
void browseWorkspaces().then(() => {
|
||||||
|
if (launch.workspaceId) {
|
||||||
|
workspaceEl.value = launch.workspaceId;
|
||||||
|
void browseDocuments().then(() => {
|
||||||
|
if (launch.documentId) {
|
||||||
|
documentEl.value = launch.documentId;
|
||||||
|
currentDocumentId = launch.documentId;
|
||||||
|
void loadDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfigDefaults pre-fills customer/library from a single-tenant deployment's
|
||||||
|
// /config when the launch context didn't supply them. Best-effort.
|
||||||
|
async function loadConfigDefaults(): Promise<void> {
|
||||||
|
if (customerEl.value && libraryEl.value) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/config', { credentials: 'include' });
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const cfg = await resp.json();
|
||||||
|
if (!customerEl.value && cfg.customerId) customerEl.value = String(cfg.customerId);
|
||||||
|
if (!libraryEl.value && cfg.libraryId) libraryEl.value = String(cfg.libraryId);
|
||||||
|
} catch {
|
||||||
|
/* no /config — the user enters the scope manually */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// browseWorkspaces fills the workspace picker from the proxy for the current
|
||||||
|
// scope. An empty scope (or an auth failure) leaves it empty with a hint.
|
||||||
|
async function browseWorkspaces(): Promise<void> {
|
||||||
|
if (!requireScope()) return;
|
||||||
|
setStatus('Loading workspaces…');
|
||||||
|
try {
|
||||||
|
const { items } = await client().listWorkspaces({ limit: 100 });
|
||||||
|
fillWorkspaces(items);
|
||||||
|
setStatus(items.length ? `Loaded ${items.length} workspaces.` : 'No workspaces visible.', items.length ? 'ok' : 'warn');
|
||||||
|
if (items.length) await browseDocuments();
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatus(e?.message || 'Could not load workspaces — is the app connected? (Connect on imanage.hanzo.ai)', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// browseDocuments lists the selected workspace's documents into the document
|
||||||
|
// picker (a workspace is a container, so its id is a valid folder id here).
|
||||||
|
async function browseDocuments(): Promise<void> {
|
||||||
|
const workspaceId = workspaceEl.value;
|
||||||
|
if (!workspaceId) return;
|
||||||
|
setStatus('Loading documents…');
|
||||||
|
try {
|
||||||
|
const { items } = await client().listFolderChildren(workspaceId, { limit: 100 });
|
||||||
|
fillDocuments(items.documents);
|
||||||
|
const ws = workspaces.find((w) => w.id === workspaceId);
|
||||||
|
meta = ws ? { workspaceName: ws.name, matterNumber: ws.matter, client: ws.client } : {};
|
||||||
|
setStatus(
|
||||||
|
`${items.documents.length} documents · ${items.folders.length} folders in ${ws?.name || workspaceId}.`,
|
||||||
|
'ok',
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatus(e?.message || 'Could not load documents.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDocument pulls the selected document's profile + content and assembles a
|
||||||
|
// single-document context the actions run over.
|
||||||
|
async function loadDocument(): Promise<void> {
|
||||||
|
const documentId = documentEl.value || currentDocumentId;
|
||||||
|
if (!documentId) {
|
||||||
|
setStatus('Pick a document first.', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentDocumentId = documentId;
|
||||||
|
setStatus('Loading document…');
|
||||||
|
const c = client();
|
||||||
|
try {
|
||||||
|
const [doc, extracted] = await Promise.all([c.getDocument(documentId), c.getDocumentContent(documentId).catch(() => null)]);
|
||||||
|
const text = extracted ?? { text: '', extracted: false, reason: 'Content could not be retrieved.' };
|
||||||
|
ctx = buildContext([documentSection(doc, text)]);
|
||||||
|
meta = { ...meta, workspaceName: meta.workspaceName };
|
||||||
|
recordsEl.textContent = `${doc.name}${doc.extension ? '.' + doc.extension : ''}${text.extracted ? '' : ' (profile only — content not extractable)'}`;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.title = `Save the result into the comment field of ${doc.name}`;
|
||||||
|
setStatus(contextNote(ctx));
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatus(e?.message || 'Could not load the document.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSearch runs a document search across the library and builds a result-set
|
||||||
|
// context for the synthesize / compare actions (index only, not full content).
|
||||||
|
async function runSearch(): Promise<void> {
|
||||||
|
const query = searchEl.value.trim();
|
||||||
|
if (!requireScope()) return;
|
||||||
|
if (!query) {
|
||||||
|
setStatus('Enter a search query.', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(`Searching for “${query}”…`);
|
||||||
|
try {
|
||||||
|
const { items, paging } = await client().search(query, { limit: 50 });
|
||||||
|
ctx = buildContext([searchResultSection(items)]);
|
||||||
|
currentDocumentId = ''; // a result set has no single document
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
recordsEl.textContent = `${items.length}${paging.hasMore ? '+' : ''} documents matched “${query}”.`;
|
||||||
|
setStatus(items.length ? contextNote(ctx) : 'No documents matched.', items.length ? '' : 'warn');
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatus(e?.message || 'Search failed.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run executes one of the named actions over the loaded context.
|
||||||
|
async function run(actionId: string): Promise<void> {
|
||||||
|
if (!isActionId(actionId)) return;
|
||||||
|
await execute((c, m, opts) => runAction(actionId, c, m, opts));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask executes a freeform question over the loaded context.
|
||||||
|
async function ask(prompt: string): Promise<void> {
|
||||||
|
await execute((c, m, opts) => askHanzo(prompt, c, m, opts));
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute is the shared runner: require a loaded context, call the model, show
|
||||||
|
// the result. Both the chips and freeform ask funnel through here (one path).
|
||||||
|
async function execute(
|
||||||
|
call: (
|
||||||
|
c: DocumentContext,
|
||||||
|
m: MatterMeta,
|
||||||
|
opts: { token: string; model: string; signal: AbortSignal },
|
||||||
|
) => Promise<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!ctx || ctx.totalBlocks === 0) {
|
||||||
|
setStatus('Load a document or run a search first.', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller?.abort();
|
||||||
|
controller = new AbortController();
|
||||||
|
setBusy(true);
|
||||||
|
setStatus(contextNote(ctx));
|
||||||
|
outputEl.value = '';
|
||||||
|
try {
|
||||||
|
const text = await call(ctx, meta, {
|
||||||
|
token: bearer(),
|
||||||
|
model: modelEl.value || DEFAULT_MODEL,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
outputEl.value = text;
|
||||||
|
setStatus('Done.', 'ok');
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === 'AbortError') setStatus('Stopped.');
|
||||||
|
else setStatus(e?.message || 'Request failed.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveToProfile writes the current output back to iManage as the loaded
|
||||||
|
// document's comment — the explicit, documented, gated write-back. Guarded: never
|
||||||
|
// posts an empty body and always confirms which document it targets.
|
||||||
|
async function saveToProfile(): Promise<void> {
|
||||||
|
const body = outputEl.value.trim();
|
||||||
|
if (!currentDocumentId) return setStatus('Load a single document to save a comment to.', 'warn');
|
||||||
|
if (!body) return setStatus('Nothing to save — run an action first.', 'warn');
|
||||||
|
if (!confirm(`Save this into the comment field of the loaded document? This updates its iManage profile.`)) return;
|
||||||
|
setBusy(true);
|
||||||
|
setStatus('Saving to iManage profile…');
|
||||||
|
try {
|
||||||
|
await client().updateDocumentProfile(currentDocumentId, { comment: body.slice(0, 4000) });
|
||||||
|
setStatus('Saved to the document comment.', 'ok');
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatus(e?.message || 'Save failed.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- small DOM helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
function requireScope(): boolean {
|
||||||
|
if (!customerEl.value.trim() || !libraryEl.value.trim()) {
|
||||||
|
setStatus('Enter your iManage customer id and library id.', 'warn');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspaces: Workspace[] = [];
|
||||||
|
function fillWorkspaces(items: Workspace[]): void {
|
||||||
|
workspaces = items;
|
||||||
|
workspaceEl.innerHTML = '';
|
||||||
|
for (const w of items) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = w.id;
|
||||||
|
opt.textContent = w.name || w.id;
|
||||||
|
workspaceEl.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fillDocuments(items: DocumentEntry[]): void {
|
||||||
|
documentEl.innerHTML = '';
|
||||||
|
for (const d of items) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = d.id;
|
||||||
|
opt.textContent = `${d.name}${d.extension ? '.' + d.extension : ''}`;
|
||||||
|
documentEl.appendChild(opt);
|
||||||
|
}
|
||||||
|
currentDocumentId = documentEl.value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateModels(): Promise<void> {
|
||||||
|
try {
|
||||||
|
fillModels(await listModels({ token: bearer() }));
|
||||||
|
} catch {
|
||||||
|
fillModels([DEFAULT_MODEL]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fillModels(ids: string[]): void {
|
||||||
|
const list = ids.length ? ids : [DEFAULT_MODEL];
|
||||||
|
modelEl.innerHTML = '';
|
||||||
|
for (const id of list) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = id;
|
||||||
|
modelEl.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (list.includes(DEFAULT_MODEL)) modelEl.value = DEFAULT_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reflectAuth(): void {
|
||||||
|
authHint.textContent = hasApiKey()
|
||||||
|
? 'Using your saved Hanzo key.'
|
||||||
|
: 'No key saved — using public models. Paste an hk-… key for your org models.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(b: boolean): void {
|
||||||
|
runBtn.disabled = b;
|
||||||
|
stopBtn.disabled = !b;
|
||||||
|
loadBtn.disabled = b;
|
||||||
|
browseBtn.disabled = b;
|
||||||
|
searchBtn.disabled = b;
|
||||||
|
for (const c of Array.from(chipRow.querySelectorAll('button'))) (c as HTMLButtonElement).disabled = b;
|
||||||
|
saveBtn.disabled = b || !currentDocumentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg: string, kind: '' | 'ok' | 'warn' | 'error' = ''): void {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.className = `status${kind ? ' ' + kind : ''}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Auth for the web panel: the zero-setup pasted-key path for the Hanzo gateway.
|
||||||
|
// The iManage panel is a static web page (iframe), so the Hanzo credential lives in
|
||||||
|
// localStorage and is validated by a real /v1/models call — a key that can't list
|
||||||
|
// models is rejected before it's saved, so the user learns at paste time, not at
|
||||||
|
// first action. Mirrors @hanzo/procore exactly (one way to hold a key).
|
||||||
|
//
|
||||||
|
// The iManage access token (for reading workspaces/documents) is a SEPARATE
|
||||||
|
// credential minted server-side by the OAuth flow and held by server.ts; the panel
|
||||||
|
// reaches iManage only through the server proxy (with its session cookie), so it
|
||||||
|
// never holds the iManage token or secret. This module is only the Hanzo-gateway
|
||||||
|
// bearer.
|
||||||
|
|
||||||
|
import { APIKEY_STORAGE_KEY, pickBearer } from './config.js';
|
||||||
|
import { listModels } from './hanzo.js';
|
||||||
|
|
||||||
|
export function getApiKey(): string {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(APIKEY_STORAGE_KEY) || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setApiKey(key: string): void {
|
||||||
|
try {
|
||||||
|
const k = key.trim();
|
||||||
|
if (k) localStorage.setItem(APIKEY_STORAGE_KEY, k);
|
||||||
|
else localStorage.removeItem(APIKEY_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* private-mode / storage disabled — the in-memory key still works this session */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearApiKey(): void {
|
||||||
|
setApiKey('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasApiKey(): boolean {
|
||||||
|
return !!getApiKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearer is the credential the chat call sends: the pasted key (or empty for
|
||||||
|
// anonymous public models). When Hanzo OAuth lands it slots in as the second
|
||||||
|
// argument to pickBearer with no change to callers.
|
||||||
|
export function bearer(): string {
|
||||||
|
return pickBearer(getApiKey(), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateKey confirms a pasted key actually works by listing models with it.
|
||||||
|
// Returns the models on success so the caller populates the picker in one
|
||||||
|
// round-trip; throws with the gateway's message on failure.
|
||||||
|
export async function validateKey(key: string): Promise<string[]> {
|
||||||
|
const k = key.trim();
|
||||||
|
if (!k) throw new Error('Enter a Hanzo API key (hk-…).');
|
||||||
|
return listModels({ token: k });
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// iManage config — the customer's iManage Work host (auth/control-center + Work
|
||||||
|
// API), the Work API version segment, the api.hanzo.ai model gateway, and the
|
||||||
|
// server-side secret set (iManage OAuth). Endpoints and the bearer choice mirror
|
||||||
|
// @hanzo/procore / @hanzo/clio so the productivity suite stays DRY; the
|
||||||
|
// legal-DMS-specific pieces (document-context windowing, the AI actions) live in
|
||||||
|
// documents.ts / hanzo.ts / actions.ts, not here.
|
||||||
|
//
|
||||||
|
// iManage is NOT a fixed sandbox↔production pair like Procore: every customer runs
|
||||||
|
// on their own host (iManage Cloud `cloudimanage.com`, a regional cloud host, or
|
||||||
|
// an on-prem Work server). So the host is a configured value, not an enum — the
|
||||||
|
// OAuth + Work API URLs are all derived from it.
|
||||||
|
|
||||||
|
// ---- Hanzo model gateway --------------------------------------------------
|
||||||
|
|
||||||
|
// Where the Hanzo model gateway lives. `@hanzo/ai` defaults here too. /v1 only,
|
||||||
|
// never an /api/ prefix (api.hanzo.ai IS the api host). NOTE: the /api/ ban is a
|
||||||
|
// Hanzo-gateway rule; iManage's OWN Work API path IS `/api/v2/...` and that is
|
||||||
|
// correct — it is iManage's host, not ours.
|
||||||
|
export const HANZO_API_BASE_URL = 'https://api.hanzo.ai';
|
||||||
|
|
||||||
|
// Default model. A Zen model (qwen3+). Overridable per-request via the picker;
|
||||||
|
// the gateway routes it.
|
||||||
|
export const DEFAULT_MODEL = 'zen5';
|
||||||
|
|
||||||
|
// Public IAM origin that mints Hanzo user tokens, and the OAuth client id an
|
||||||
|
// inbound Hanzo token is audienced to (owner-scoping validation via @hanzo/iam).
|
||||||
|
export const DEFAULT_IAM_SERVER_URL = 'https://hanzo.id';
|
||||||
|
export const DEFAULT_IAM_CLIENT_ID = 'hanzo-imanage';
|
||||||
|
|
||||||
|
// localStorage key for the pasted Hanzo API key (`hk-…`) in the web panel. The
|
||||||
|
// panel is a static web page (iframe), not a host with roamingSettings, so the
|
||||||
|
// zero-setup credential lives in localStorage — same as @hanzo/procore.
|
||||||
|
export const APIKEY_STORAGE_KEY = 'hanzo.imanage.apiKey';
|
||||||
|
|
||||||
|
// Document text budget. A legal document (or a matter's document set) is far
|
||||||
|
// larger than a model window and must not be sent whole. This caps the characters
|
||||||
|
// of iManage document text we attach to any one request — chosen so it fits
|
||||||
|
// comfortably inside a modern context window alongside the reply, and is honest
|
||||||
|
// rather than optimal (we truncate visibly, never drop silently).
|
||||||
|
export const DOCUMENT_CHAR_BUDGET = 60_000;
|
||||||
|
|
||||||
|
// pickBearer chooses the credential to send to the Hanzo gateway: a pasted API
|
||||||
|
// key wins over an OAuth token (an explicit key is a deliberate override), else
|
||||||
|
// the token, else empty (anonymous — the gateway still serves public models).
|
||||||
|
// Pure — unit-tested.
|
||||||
|
export function pickBearer(apiKey: string, oauthToken: string): string {
|
||||||
|
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatCompletionsURL / modelsURL — the model gateway endpoints.
|
||||||
|
export function chatCompletionsURL(): string {
|
||||||
|
return `${HANZO_API_BASE_URL}/v1/chat/completions`;
|
||||||
|
}
|
||||||
|
export function modelsURL(): string {
|
||||||
|
return `${HANZO_API_BASE_URL}/v1/models`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- iManage Work hosts + Work API ----------------------------------------
|
||||||
|
//
|
||||||
|
// One iManage customer has (usually) ONE host serving both the OAuth
|
||||||
|
// control-center endpoints (/auth/oauth2/authorize + /token) and the Work API
|
||||||
|
// (/api/v2/...). Some deployments front the Work API on a separate host, so we
|
||||||
|
// allow an optional API host that defaults to the auth host. We never hard-code a
|
||||||
|
// single host inline — every URL is built from a normalized host string, so
|
||||||
|
// pointing at a different customer's iManage is one env value.
|
||||||
|
//
|
||||||
|
// iManage Cloud (US): https://cloudimanage.com
|
||||||
|
// regional cloud: https://<region>.imanage.work
|
||||||
|
// on-prem Work server: https://work.<firm>.com
|
||||||
|
//
|
||||||
|
// Docs: docs.imanage.com (Work API) and the iManage Control Center OAuth2 guide.
|
||||||
|
|
||||||
|
export interface ImanageHosts {
|
||||||
|
/** Auth / control-center host — /auth/oauth2/authorize and /token live here. */
|
||||||
|
auth: string;
|
||||||
|
/** Work API host — /api/v2/... lives here. */
|
||||||
|
api: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeHost coerces a host value into a clean `https://host[:port]` origin:
|
||||||
|
// adds https:// when a bare host is given, and strips any trailing slash / path so
|
||||||
|
// the endpoint builders can append their own paths cleanly. Pure — unit-tested.
|
||||||
|
export function normalizeHost(host: string): string {
|
||||||
|
const trimmed = (host || '').trim();
|
||||||
|
if (trimmed === '') return '';
|
||||||
|
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||||
|
try {
|
||||||
|
return new URL(withScheme).origin;
|
||||||
|
} catch {
|
||||||
|
// Fall back to a best-effort strip so a malformed value never throws here;
|
||||||
|
// isHttpUrl is the boundary guard that rejects it in readServerConfig.
|
||||||
|
return withScheme.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHttpUrl narrows an arbitrary string to a usable http(s) origin. Boundary guard
|
||||||
|
// used by readServerConfig so the service refuses to start against a nonsense host.
|
||||||
|
export function isHttpUrl(v: string | undefined): boolean {
|
||||||
|
if (!v) return false;
|
||||||
|
// A value that already carries a scheme keeps it (so ftp:// etc. is rejected);
|
||||||
|
// a bare host is assumed https.
|
||||||
|
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(v);
|
||||||
|
try {
|
||||||
|
const u = new URL(hasScheme ? v : `https://${v}`);
|
||||||
|
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hosts resolves the auth + api host set from a configured host (and an optional
|
||||||
|
// distinct API host). Pure — the request wrappers take the resolved strings.
|
||||||
|
export function hosts(host: string, apiHost?: string): ImanageHosts {
|
||||||
|
const auth = normalizeHost(host);
|
||||||
|
const api = apiHost && apiHost.trim() ? normalizeHost(apiHost) : auth;
|
||||||
|
return { auth, api };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Work API version segment. v2 is current for the resources we read
|
||||||
|
// (workspaces, folders, documents, search); we never bump to a speculative v3 —
|
||||||
|
// one version, forward only.
|
||||||
|
export const WORK_API_VERSION = 'v2';
|
||||||
|
|
||||||
|
// apiBaseUrl turns an API host into the Work API root: `${api}/api/v2`. Every
|
||||||
|
// iManage Work API call is built on top of this. Pure — the request wrappers in
|
||||||
|
// imanage-api.ts take this string. (The `/api/` here is iManage's own path.)
|
||||||
|
export function apiBaseUrl(apiHost: string): string {
|
||||||
|
return `${normalizeHost(apiHost)}/api/${WORK_API_VERSION}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The OAuth scopes the app requests. iManage's Authorization Code grant treats
|
||||||
|
// scope as optional (access is governed by the user's Work permissions and the
|
||||||
|
// app registration in Control Center); we send it only when non-empty, leaving
|
||||||
|
// room for future fine-grained scopes without a code change.
|
||||||
|
export const OAUTH_SCOPES = [] as const;
|
||||||
|
|
||||||
|
// ---- Server-side configuration (iManage OAuth) ----------------------------
|
||||||
|
//
|
||||||
|
// These are read from the environment by src/server.ts. They NEVER reach the
|
||||||
|
// browser bundle: the client id + host are non-secret, but the client secret is
|
||||||
|
// server-only and is validated to be present before the server will start
|
||||||
|
// (readServerConfig throws on a missing secret). This is the ONLY place the secret
|
||||||
|
// exists.
|
||||||
|
//
|
||||||
|
// customerId / libraryId are the (non-secret) scoping defaults a single-tenant
|
||||||
|
// deployment fixes so the panel opens pre-scoped; they are optional (the panel can
|
||||||
|
// also take them from its launch-context query or manual fields).
|
||||||
|
|
||||||
|
export interface ServerConfig {
|
||||||
|
/** iManage OAuth client id (public). */
|
||||||
|
imanageClientId: string;
|
||||||
|
/** iManage OAuth client secret (SERVER ONLY — token exchange + refresh). */
|
||||||
|
imanageClientSecret: string;
|
||||||
|
/** OAuth redirect registered on the app (e.g. https://imanage.hanzo.ai/oauth/callback). */
|
||||||
|
imanageRedirectUri: string;
|
||||||
|
/** The customer's iManage host (auth control-center + Work API unless apiHost set). */
|
||||||
|
imanageHost: string;
|
||||||
|
/** Optional distinct Work API host; defaults to imanageHost. */
|
||||||
|
imanageApiHost?: string;
|
||||||
|
/** Non-secret default customer id the panel opens scoped to (optional). */
|
||||||
|
customerId: string;
|
||||||
|
/** Non-secret default library id the panel opens scoped to (optional). */
|
||||||
|
libraryId: string;
|
||||||
|
/** Listen port. */
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// readServerConfig fails fast (throws) if a required iManage secret/host is
|
||||||
|
// missing or malformed — a server that cannot exchange OAuth codes or reach a Work
|
||||||
|
// host must not pretend to start. Pure given an env map, so it is unit-tested
|
||||||
|
// without touching process.env.
|
||||||
|
export function readServerConfig(env: Record<string, string | undefined>): ServerConfig {
|
||||||
|
const imanageClientId = env.IMANAGE_CLIENT_ID;
|
||||||
|
const imanageClientSecret = env.IMANAGE_CLIENT_SECRET;
|
||||||
|
const imanageRedirectUri = env.IMANAGE_REDIRECT_URI;
|
||||||
|
const imanageHost = env.IMANAGE_HOST;
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!imanageClientId) missing.push('IMANAGE_CLIENT_ID');
|
||||||
|
if (!imanageClientSecret) missing.push('IMANAGE_CLIENT_SECRET');
|
||||||
|
if (!imanageRedirectUri) missing.push('IMANAGE_REDIRECT_URI');
|
||||||
|
if (!imanageHost) missing.push('IMANAGE_HOST');
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`Missing required environment: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (!isHttpUrl(imanageHost)) {
|
||||||
|
throw new Error(`IMANAGE_HOST is not a valid http(s) host: ${imanageHost}`);
|
||||||
|
}
|
||||||
|
if (env.IMANAGE_API_HOST && !isHttpUrl(env.IMANAGE_API_HOST)) {
|
||||||
|
throw new Error(`IMANAGE_API_HOST is not a valid http(s) host: ${env.IMANAGE_API_HOST}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
imanageClientId: imanageClientId!,
|
||||||
|
imanageClientSecret: imanageClientSecret!,
|
||||||
|
imanageRedirectUri: imanageRedirectUri!,
|
||||||
|
imanageHost: normalizeHost(imanageHost!),
|
||||||
|
imanageApiHost: env.IMANAGE_API_HOST ? normalizeHost(env.IMANAGE_API_HOST) : undefined,
|
||||||
|
customerId: env.IMANAGE_CUSTOMER_ID?.trim() || '',
|
||||||
|
libraryId: env.IMANAGE_LIBRARY_ID?.trim() || '',
|
||||||
|
port: Number(env.PORT) || 8793,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Document-context assembly — PURE, host-agnostic, fully unit-testable. Turns the
|
||||||
|
// typed iManage records (document profiles, extracted content, workspaces, folder
|
||||||
|
// listings, search results) into the plain text an AI action reads, windowed to a
|
||||||
|
// character budget so a large legal document (or matter set) never overflows the
|
||||||
|
// model or gets sent silently truncated.
|
||||||
|
//
|
||||||
|
// There is ONE windowing contract, identical in spirit to @hanzo/procore's project
|
||||||
|
// windowing: render ordered blocks, walk them in order, stop at the budget, always
|
||||||
|
// include at least the first block, and report `truncated` honestly. The only
|
||||||
|
// legal-DMS-specific part is how a record (or a document's content) renders to a
|
||||||
|
// block.
|
||||||
|
|
||||||
|
import type { DocumentEntry, FolderEntry, Workspace } from './imanage-api.js';
|
||||||
|
import { DOCUMENT_CHAR_BUDGET } from './config.js';
|
||||||
|
|
||||||
|
// ---- Text extraction ------------------------------------------------------
|
||||||
|
//
|
||||||
|
// getDocumentContent returns the stored bytes. We assume text or exported text;
|
||||||
|
// binary formats (native .docx/.pdf without server-side export, images) are OUT OF
|
||||||
|
// SCOPE and reported honestly rather than fed as garbage. looksBinary is a cheap
|
||||||
|
// heuristic: a NUL byte, or a high share of non-printable control characters, means
|
||||||
|
// we cannot treat it as text here.
|
||||||
|
|
||||||
|
// looksBinary reports whether a fetched content string is (probably) not plain
|
||||||
|
// text. Pure — samples the head so it stays cheap on large documents.
|
||||||
|
export function looksBinary(raw: string): boolean {
|
||||||
|
if (raw.length === 0) return false;
|
||||||
|
const sample = raw.slice(0, 4096);
|
||||||
|
let control = 0;
|
||||||
|
for (let i = 0; i < sample.length; i++) {
|
||||||
|
const c = sample.charCodeAt(i);
|
||||||
|
if (c === 0) return true; // a NUL byte is a reliable binary tell
|
||||||
|
// Allow tab (9), LF (10), CR (13); count other C0 controls + DEL as binary.
|
||||||
|
if ((c < 32 && c !== 9 && c !== 10 && c !== 13) || c === 127) control++;
|
||||||
|
}
|
||||||
|
return control / sample.length > 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The result of extracting text from fetched content: the text (empty when not
|
||||||
|
// extractable) and an honest reason when nothing usable came out.
|
||||||
|
export interface ExtractedText {
|
||||||
|
text: string;
|
||||||
|
extracted: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractText normalizes fetched content to context text. Binary / empty content
|
||||||
|
// yields extracted:false with a reason the caller surfaces (never fabricated
|
||||||
|
// text). Whitespace is collapsed lightly so the model sees clean prose. Pure.
|
||||||
|
export function extractText(raw: string, name?: string): ExtractedText {
|
||||||
|
const label = name ? ` (${name})` : '';
|
||||||
|
if (!raw || raw.trim().length === 0) {
|
||||||
|
return { text: '', extracted: false, reason: `The document${label} has no extractable text content.` };
|
||||||
|
}
|
||||||
|
if (looksBinary(raw)) {
|
||||||
|
return {
|
||||||
|
text: '',
|
||||||
|
extracted: false,
|
||||||
|
reason: `The document${label} is a binary format (native or image) — text extraction / OCR is out of scope here; summarize from the profile metadata only.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const text = raw.replace(/\r\n/g, '\n').replace(/[ \t]+\n/g, '\n').trim();
|
||||||
|
return { text, extracted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Record → text block --------------------------------------------------
|
||||||
|
|
||||||
|
// renderDocumentMeta turns a document profile into a labelled metadata block.
|
||||||
|
// Empty fields are omitted rather than rendered as blank lines. Pure.
|
||||||
|
export function renderDocumentMeta(doc: DocumentEntry): string {
|
||||||
|
const head = `Document: ${doc.name || doc.id}`.trim();
|
||||||
|
const meta: string[] = [];
|
||||||
|
if (doc.extension) meta.push(`Type: .${doc.extension}`);
|
||||||
|
if (doc.class) meta.push(`Class: ${doc.class}`);
|
||||||
|
if (doc.author) meta.push(`Author: ${doc.author}`);
|
||||||
|
if (doc.version !== '' && doc.version !== undefined) meta.push(`Version: ${doc.version}`);
|
||||||
|
if (doc.editDate) meta.push(`Edited: ${doc.editDate}`);
|
||||||
|
const lines = [head];
|
||||||
|
if (meta.length) lines.push(meta.join(' · '));
|
||||||
|
if (doc.comment) lines.push(`Comment: ${doc.comment.trim()}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderWorkspace turns one workspace (matter container) into a labelled block.
|
||||||
|
export function renderWorkspace(w: Workspace): string {
|
||||||
|
const head = `Workspace: ${w.name || w.id}`.trim();
|
||||||
|
const meta: string[] = [];
|
||||||
|
if (w.client) meta.push(`Client: ${w.client}`);
|
||||||
|
if (w.matter) meta.push(`Matter: ${w.matter}`);
|
||||||
|
const lines = [meta.length ? `${head}\n${meta.join(' · ')}` : head];
|
||||||
|
if (w.description) lines.push(w.description.trim());
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDocumentLine turns one document entry into a single labelled line (for a
|
||||||
|
// folder listing or a search result set — the index, not the content).
|
||||||
|
export function renderDocumentLine(doc: DocumentEntry): string {
|
||||||
|
const ext = doc.extension ? `.${doc.extension}` : '';
|
||||||
|
const who = doc.author ? ` — ${doc.author}` : '';
|
||||||
|
const when = doc.editDate ? ` (${doc.editDate})` : '';
|
||||||
|
return `Doc: ${doc.name || doc.id}${ext}${who}${when}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderFolderLine turns one folder entry into a single labelled line.
|
||||||
|
export function renderFolderLine(f: FolderEntry): string {
|
||||||
|
return `Folder: ${f.name || f.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentBlocks splits a document's extracted text into paragraph blocks so the
|
||||||
|
// windowing algorithm can include as much as fits and cut on a paragraph boundary
|
||||||
|
// rather than mid-sentence. Pure.
|
||||||
|
export function contentBlocks(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((b) => b.trim())
|
||||||
|
.filter((b) => b.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Windowing to a budget ------------------------------------------------
|
||||||
|
|
||||||
|
// A named group of rendered blocks — a section of the context (e.g. "Document
|
||||||
|
// profile", "Contract text", "Search results"). The assembler concatenates
|
||||||
|
// sections in the order given and windows the whole thing to the budget.
|
||||||
|
export interface Section {
|
||||||
|
title: string;
|
||||||
|
blocks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The windowed document context: the rendered text, how many blocks were available
|
||||||
|
// vs included, and whether anything was dropped (so the prompt and UI can say so
|
||||||
|
// honestly).
|
||||||
|
export interface DocumentContext {
|
||||||
|
text: string;
|
||||||
|
totalBlocks: number;
|
||||||
|
includedBlocks: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildContext concatenates sections IN ORDER and caps the rendered text at
|
||||||
|
// `budget` characters. It walks blocks in order (across sections) and stops when
|
||||||
|
// adding the next block would exceed the budget — always including at least the
|
||||||
|
// first block (hard-cut to the budget if that one block alone is over).
|
||||||
|
// `truncated` is true whenever not every available block made it in. Pure and
|
||||||
|
// total: deterministic, no I/O. This is the ONE windowing algorithm for the
|
||||||
|
// package — the same "fit ordered text to a budget" contract as @hanzo/procore.
|
||||||
|
export function buildContext(
|
||||||
|
sections: Section[],
|
||||||
|
budget: number = DOCUMENT_CHAR_BUDGET,
|
||||||
|
): DocumentContext {
|
||||||
|
const totalBlocks = sections.reduce((n, s) => n + s.blocks.length, 0);
|
||||||
|
if (totalBlocks === 0) {
|
||||||
|
return { text: '', totalBlocks: 0, includedBlocks: 0, truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
let used = 0;
|
||||||
|
let included = 0;
|
||||||
|
let hardCut = false;
|
||||||
|
|
||||||
|
outer: for (const section of sections) {
|
||||||
|
if (section.blocks.length === 0) continue;
|
||||||
|
// The section header is charged to the first block that fits under it.
|
||||||
|
let headerPending = `## ${section.title}\n`;
|
||||||
|
for (const block of section.blocks) {
|
||||||
|
const prefix = parts.length === 0 ? '' : '\n\n';
|
||||||
|
const addition = prefix + headerPending + block;
|
||||||
|
if (used + addition.length > budget) {
|
||||||
|
if (parts.length === 0) {
|
||||||
|
// First block alone overflows — hard-cut it to the budget so we always
|
||||||
|
// send something rather than an empty context.
|
||||||
|
const solo = (headerPending + block).slice(0, budget);
|
||||||
|
parts.push(solo);
|
||||||
|
used = solo.length;
|
||||||
|
included = 1;
|
||||||
|
hardCut = true;
|
||||||
|
}
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
parts.push(addition);
|
||||||
|
used += addition.length;
|
||||||
|
included += 1;
|
||||||
|
headerPending = ''; // header only precedes the first block of the section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = included < totalBlocks || hardCut;
|
||||||
|
return { text: parts.join(''), totalBlocks, includedBlocks: included, truncated };
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextNote is the one honest sentence prepended to the document text so the
|
||||||
|
// model (and, echoed in the panel, the user) knows the scope of what it sees.
|
||||||
|
// Never claim the whole document/matter was sent when it wasn't. Pure.
|
||||||
|
export function contextNote(ctx: DocumentContext): string {
|
||||||
|
if (ctx.totalBlocks === 0) return 'No document content or records were available.';
|
||||||
|
return ctx.truncated
|
||||||
|
? `Document context: ${ctx.includedBlocks} of ${ctx.totalBlocks} sections (truncated to fit — answer only from what is shown and say so if the omitted part is needed).`
|
||||||
|
: `Document context: all ${ctx.totalBlocks} section${ctx.totalBlocks === 1 ? '' : 's'}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Convenience section builders -----------------------------------------
|
||||||
|
//
|
||||||
|
// These turn typed records into a Section, so a caller (server or panel) assembles
|
||||||
|
// a context in a couple of lines. Kept here (pure) so the same section shapes feed
|
||||||
|
// both surfaces and the tests.
|
||||||
|
|
||||||
|
// documentSection assembles ONE document: its profile metadata plus its extracted
|
||||||
|
// content (paragraph-blocked). When content was not extractable, the reason rides
|
||||||
|
// as the section's single content block so the model knows to work from metadata.
|
||||||
|
export function documentSection(doc: DocumentEntry, extracted: ExtractedText, title = 'Document'): Section {
|
||||||
|
const blocks = [renderDocumentMeta(doc)];
|
||||||
|
if (extracted.extracted && extracted.text) blocks.push(...contentBlocks(extracted.text));
|
||||||
|
else if (extracted.reason) blocks.push(`[${extracted.reason}]`);
|
||||||
|
return { title, blocks };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function documentIndexSection(docs: DocumentEntry[], title = 'Documents'): Section {
|
||||||
|
return { title, blocks: docs.map(renderDocumentLine) };
|
||||||
|
}
|
||||||
|
export function folderSection(folders: FolderEntry[], title = 'Folders'): Section {
|
||||||
|
return { title, blocks: folders.map(renderFolderLine) };
|
||||||
|
}
|
||||||
|
export function workspaceSection(workspaces: Workspace[], title = 'Workspaces'): Section {
|
||||||
|
return { title, blocks: workspaces.map(renderWorkspace) };
|
||||||
|
}
|
||||||
|
export function searchResultSection(docs: DocumentEntry[], title = 'Search results'): Section {
|
||||||
|
return { title, blocks: docs.map(renderDocumentLine) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// The Hanzo call and its request/response shaping over a DOCUMENT / MATTER context
|
||||||
|
// — thin over the published `@hanzo/ai` headless client, host-agnostic, and fully
|
||||||
|
// unit-testable (no iManage SDK, no DOM). The panel and the server are the glue
|
||||||
|
// that read a document's profile + content and hand them here. Speaks the
|
||||||
|
// OpenAI-compatible /v1 wire protocol the whole Hanzo suite uses (identical to
|
||||||
|
// @hanzo/procore) so the gateway sees one shape from every surface.
|
||||||
|
//
|
||||||
|
// @hanzo/ai@^0.2.0 IS the headless client: createAiClient({ baseUrl, token })
|
||||||
|
// → .chat.completions.create({ model, messages }) + .models.list(). We import it
|
||||||
|
// and do NOT reimplement the transport. This module adds only the document-aware
|
||||||
|
// prompt assembly + the single `ask` primitive the actions layer on.
|
||||||
|
|
||||||
|
import {
|
||||||
|
createAiClient,
|
||||||
|
type AiClient,
|
||||||
|
type ChatCompletion,
|
||||||
|
type ChatCompletionMessage,
|
||||||
|
} from '@hanzo/ai';
|
||||||
|
import { DEFAULT_MODEL, HANZO_API_BASE_URL } from './config.js';
|
||||||
|
import { contextNote, type DocumentContext } from './documents.js';
|
||||||
|
|
||||||
|
// SYSTEM_PROMPT grounds every answer in the iManage records. It forbids inventing
|
||||||
|
// clauses, dates, parties, or terms that nobody wrote (the failure mode that makes
|
||||||
|
// a legal assistant dangerous), asks it to cite the document name/number, and
|
||||||
|
// stays honest about truncated context. It states plainly that the output is
|
||||||
|
// informational and NOT legal advice — a review tool that pretends otherwise is a
|
||||||
|
// liability — and reinforces the confidentiality posture (work only from what is
|
||||||
|
// shown; do not restate more of the privileged text than the task needs).
|
||||||
|
export const SYSTEM_PROMPT =
|
||||||
|
'You are Hanzo AI, an assistant that helps legal and professional teams work ' +
|
||||||
|
'with documents in their iManage Work matter — contracts, correspondence, ' +
|
||||||
|
'pleadings, and memos. Work ONLY from the document content and profile records ' +
|
||||||
|
'provided below — never invent clauses, dates, parties, defined terms, dollar ' +
|
||||||
|
'amounts, or facts that are not supported by the text. Cite the document name or ' +
|
||||||
|
'number when the text carries it, and quote exact contract language when a clause ' +
|
||||||
|
'or figure matters. Be precise, neutral, and concise. If the records are marked ' +
|
||||||
|
'truncated and a complete answer needs the omitted part, say so plainly rather ' +
|
||||||
|
'than guessing. These are confidential, potentially privileged materials: reveal ' +
|
||||||
|
'no more of the text than the task requires. You provide informational document ' +
|
||||||
|
'review support, NOT legal advice, and you do not create an attorney-client ' +
|
||||||
|
'relationship.';
|
||||||
|
|
||||||
|
// ---- Prompt assembly ------------------------------------------------------
|
||||||
|
|
||||||
|
// Optional structured context about the matter (workspace name, matter number,
|
||||||
|
// client) the iManage records supply. Attached as a short header above the records
|
||||||
|
// so answers can reference the matter without the model guessing.
|
||||||
|
export interface MatterMeta {
|
||||||
|
workspaceName?: string;
|
||||||
|
matterNumber?: string;
|
||||||
|
client?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matterHeader(meta: MatterMeta | undefined): string {
|
||||||
|
if (!meta) return '';
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (meta.workspaceName) parts.push(`Workspace: ${meta.workspaceName}`);
|
||||||
|
if (meta.matterNumber) parts.push(`Matter #: ${meta.matterNumber}`);
|
||||||
|
if (meta.client) parts.push(`Client: ${meta.client}`);
|
||||||
|
return parts.length > 0 ? parts.join('\n') + '\n\n' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMessages turns a task (the user's question, or one of the action prompts)
|
||||||
|
// plus the windowed document context + optional matter metadata into the
|
||||||
|
// OpenAI-compatible message list. The records are fenced so the model treats them
|
||||||
|
// as data, not instructions, and the honest context note rides inside the user
|
||||||
|
// turn so it is never lost. Pure — unit-tested.
|
||||||
|
export function buildMessages(
|
||||||
|
task: string,
|
||||||
|
ctx: DocumentContext,
|
||||||
|
meta?: MatterMeta,
|
||||||
|
): ChatCompletionMessage[] {
|
||||||
|
const system: ChatCompletionMessage = { role: 'system', content: SYSTEM_PROMPT };
|
||||||
|
const header = matterHeader(meta);
|
||||||
|
const user: ChatCompletionMessage = {
|
||||||
|
role: 'user',
|
||||||
|
content:
|
||||||
|
`${header}${contextNote(ctx)}\n\n` +
|
||||||
|
`---- document records ----\n${ctx.text}\n---- end document records ----\n\n` +
|
||||||
|
`---- task ----\n${task}`,
|
||||||
|
};
|
||||||
|
return [system, user];
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractContent pulls the assistant text out of a @hanzo/ai ChatCompletion,
|
||||||
|
// tolerating the string-or-parts content shape the OpenAI schema allows. Throws on
|
||||||
|
// empty content so callers surface the real gateway state. Pure — unit-tested.
|
||||||
|
export function extractContent(res: ChatCompletion): string {
|
||||||
|
const msg = res?.choices?.[0]?.message;
|
||||||
|
const content = msg?.content;
|
||||||
|
let text = '';
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
text = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
text = content.map((part) => (part?.type === 'text' ? part.text : '')).join('');
|
||||||
|
}
|
||||||
|
if (text.length === 0) throw new Error('Hanzo API returned no content');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- The single call path -------------------------------------------------
|
||||||
|
|
||||||
|
export interface AskOptions {
|
||||||
|
model?: string;
|
||||||
|
temperature?: number;
|
||||||
|
token?: string;
|
||||||
|
baseURL?: string;
|
||||||
|
/** Injected client (tests). Defaults to a real createAiClient. */
|
||||||
|
client?: AiClient;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// client resolves the @hanzo/ai client for a call: an injected one (tests) or a
|
||||||
|
// fresh createAiClient pointed at the gateway with the caller's bearer.
|
||||||
|
function client(opts: AskOptions): AiClient {
|
||||||
|
return (
|
||||||
|
opts.client ??
|
||||||
|
createAiClient({ token: opts.token, baseUrl: opts.baseURL ?? HANZO_API_BASE_URL })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask runs ONE non-streaming completion over a document/matter context and returns
|
||||||
|
// the answer. This is the single path from every iManage surface to the model —
|
||||||
|
// the actions, a freeform question, and any server-side call all funnel here. token
|
||||||
|
// may be empty (the gateway serves anonymous/limited models).
|
||||||
|
export async function ask(
|
||||||
|
task: string,
|
||||||
|
ctx: DocumentContext,
|
||||||
|
meta: MatterMeta | undefined,
|
||||||
|
opts: AskOptions = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await client(opts).chat.completions.create(
|
||||||
|
{
|
||||||
|
model: opts.model ?? DEFAULT_MODEL,
|
||||||
|
messages: buildMessages(task, ctx, meta),
|
||||||
|
stream: false,
|
||||||
|
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
|
||||||
|
},
|
||||||
|
{ signal: opts.signal },
|
||||||
|
);
|
||||||
|
return extractContent(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// listModels returns the model ids the caller may route to, from /v1/models via the
|
||||||
|
// headless client. The endpoint is org-scoped by the bearer; an empty token lists
|
||||||
|
// public models.
|
||||||
|
export async function listModels(opts: AskOptions = {}): Promise<string[]> {
|
||||||
|
const models = await client(opts).models.list({ signal: opts.signal });
|
||||||
|
return models.map((m) => m.id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
// iManage Work API v2 — pure request shaping + response parsing. Every function
|
||||||
|
// returns a PreparedRequest (url + headers [+ body]) or parses a response body;
|
||||||
|
// none opens a socket, so the whole API surface is unit-testable without a
|
||||||
|
// network. server.ts is the thin glue that fetches these shapes with the OAuth
|
||||||
|
// access token.
|
||||||
|
//
|
||||||
|
// The Work API root is `${api}/api/v2` (see config.apiBaseUrl). Everything is
|
||||||
|
// scoped by a CUSTOMER and a LIBRARY in the path:
|
||||||
|
// /customers/{customer_id}/libraries/{library_id}/...
|
||||||
|
// (customer = the iManage account/tenant; library = a document database, e.g.
|
||||||
|
// `ACTIVE_US`). Document ids are opaque strings, typically `{library}!{num}.{ver}`
|
||||||
|
// e.g. `ACTIVE_US!4567.1`.
|
||||||
|
//
|
||||||
|
// Auth: the OAuth access token is sent as the `X-Auth-Token` header — iManage's
|
||||||
|
// Work API convention (NOT `Authorization: Bearer`). We keep that in ONE place.
|
||||||
|
//
|
||||||
|
// Docs: docs.imanage.com — Work API v2 (workspaces, folders, documents, search).
|
||||||
|
|
||||||
|
// A prepared GET: url + headers a single fetch needs.
|
||||||
|
export interface PreparedGet {
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A prepared write (POST/PATCH): adds the JSON body and Content-Type.
|
||||||
|
export interface PreparedWrite extends PreparedGet {
|
||||||
|
method: 'POST' | 'PATCH';
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The auth header name iManage's Work API expects the access token in.
|
||||||
|
export const AUTH_HEADER = 'X-Auth-Token';
|
||||||
|
|
||||||
|
// headers builds the auth header set common to every request. iManage takes the
|
||||||
|
// OAuth access token in X-Auth-Token; the customer/library scope is in the PATH,
|
||||||
|
// not a header, so nothing else is common.
|
||||||
|
function headers(accessToken: string): Record<string, string> {
|
||||||
|
return { [AUTH_HEADER]: accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonHeaders adds Content-Type for a write body on top of the auth header.
|
||||||
|
function jsonHeaders(accessToken: string): Record<string, string> {
|
||||||
|
return { ...headers(accessToken), 'Content-Type': 'application/json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scope every request carries: the Work API root, the access token, and which
|
||||||
|
// customer + library. Grouping them keeps every wrapper's signature small and
|
||||||
|
// makes the two-level scoping impossible to forget.
|
||||||
|
export interface Scope {
|
||||||
|
apiBase: string;
|
||||||
|
accessToken: string;
|
||||||
|
customerId: string | number;
|
||||||
|
libraryId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination controls. iManage's Work API paginates with `offset` + `limit` query
|
||||||
|
// params and reports total/overflow in the response BODY (not a header) — see
|
||||||
|
// parsePaging. We attach these on list/search endpoints.
|
||||||
|
export interface Page {
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEFAULT_LIMIT is iManage's practical page size; the Work API caps a page, so we
|
||||||
|
// clamp to a documented-safe maximum to minimise round-trips without over-asking.
|
||||||
|
export const DEFAULT_LIMIT = 100;
|
||||||
|
|
||||||
|
// pageParams renders pagination into query pairs, clamping `limit` to the maximum
|
||||||
|
// and dropping non-positive values. Only emits params that are set, so an
|
||||||
|
// unpaginated call stays clean. Pure — unit-tested.
|
||||||
|
export function pageParams(page: Page | undefined): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (!page) return out;
|
||||||
|
if (typeof page.offset === 'number' && page.offset > 0) out.offset = String(Math.floor(page.offset));
|
||||||
|
if (typeof page.limit === 'number' && page.limit > 0) {
|
||||||
|
out.limit = String(Math.min(Math.floor(page.limit), DEFAULT_LIMIT));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUrl joins the Work API root + a customer/library-scoped path and appends a
|
||||||
|
// query string, dropping undefined/empty values. One place builds every URL so
|
||||||
|
// scoping/pagination params are applied consistently. Pure.
|
||||||
|
function buildUrl(scope: Scope, path: string, query: Record<string, string> = {}): string {
|
||||||
|
const base = `${scope.apiBase.replace(/\/+$/, '')}${libBase(scope)}${path}`;
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(query)) {
|
||||||
|
if (v !== undefined && v !== '') q.set(k, v);
|
||||||
|
}
|
||||||
|
const qs = q.toString();
|
||||||
|
return qs ? `${base}?${qs}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// libBase is the customer/library path prefix every scoped resource hangs off.
|
||||||
|
// Exported so the panel's proxy client builds the exact same relative paths.
|
||||||
|
export function libBase(scope: Pick<Scope, 'customerId' | 'libraryId'>): string {
|
||||||
|
return `/customers/${enc(scope.customerId)}/libraries/${enc(scope.libraryId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// enc encodes a single path segment. iManage document ids carry `!` and `.`
|
||||||
|
// (`ACTIVE_US!4567.1`); we encode defensively so a stray value can never break —
|
||||||
|
// or traverse — the path.
|
||||||
|
export function enc(segment: number | string): string {
|
||||||
|
return encodeURIComponent(String(segment));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Libraries ------------------------------------------------------------
|
||||||
|
|
||||||
|
// listLibraries — the document libraries the customer exposes. Customer-scoped
|
||||||
|
// (no library in the path). Used to discover a library id when one is not fixed.
|
||||||
|
export function listLibraries(scope: Scope): PreparedGet {
|
||||||
|
return {
|
||||||
|
url: `${scope.apiBase.replace(/\/+$/, '')}/customers/${enc(scope.customerId)}/libraries`,
|
||||||
|
headers: headers(scope.accessToken),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Workspaces -----------------------------------------------------------
|
||||||
|
|
||||||
|
// listWorkspaces — the workspaces (matters / client-matter containers) in the
|
||||||
|
// library the caller can see. The panel's picker uses this to choose which
|
||||||
|
// workspace to run against.
|
||||||
|
export function listWorkspaces(scope: Scope, page?: Page): PreparedGet {
|
||||||
|
return {
|
||||||
|
url: buildUrl(scope, '/workspaces', pageParams(page)),
|
||||||
|
headers: headers(scope.accessToken),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWorkspace — one workspace's profile (name, client, matter, description).
|
||||||
|
export function getWorkspace(scope: Scope, workspaceId: string): PreparedGet {
|
||||||
|
return {
|
||||||
|
url: buildUrl(scope, `/workspaces/${enc(workspaceId)}`),
|
||||||
|
headers: headers(scope.accessToken),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Folders + documents (browse) -----------------------------------------
|
||||||
|
|
||||||
|
// listFolderChildren — the contents of a container (a workspace or a folder): its
|
||||||
|
// subfolders AND its documents, in one paginated call. iManage's `/children`
|
||||||
|
// endpoint returns a mixed collection with a `type`/`wstype` discriminator per
|
||||||
|
// entry; parseFolderChildren splits it into folders + documents. This is the ONE
|
||||||
|
// browse endpoint (a workspace id is a valid container id here).
|
||||||
|
export function listFolderChildren(scope: Scope, folderId: string, page?: Page): PreparedGet {
|
||||||
|
return {
|
||||||
|
url: buildUrl(scope, `/folders/${enc(folderId)}/children`, pageParams(page)),
|
||||||
|
headers: headers(scope.accessToken),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Documents ------------------------------------------------------------
|
||||||
|
|
||||||
|
// getDocument — one document's full profile (metadata): name, extension, size,
|
||||||
|
// author/operator, class/type, doc number, version, dates, comment, and the
|
||||||
|
// workspace/database it lives in. This is what summarize / extract-clauses read
|
||||||
|
// alongside the extracted content.
|
||||||
|
export function getDocument(scope: Scope, documentId: string): PreparedGet {
|
||||||
|
return {
|
||||||
|
url: buildUrl(scope, `/documents/${enc(documentId)}`),
|
||||||
|
headers: headers(scope.accessToken),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDocumentContent — the document's content bytes for text extraction. iManage's
|
||||||
|
// `/download` returns the stored file; we assume text or exported text (binary /
|
||||||
|
// OCR is out of scope, handled honestly in documents.extractText). Returns a
|
||||||
|
// PreparedGet whose response the caller reads as text.
|
||||||
|
export function getDocumentContent(scope: Scope, documentId: string): PreparedGet {
|
||||||
|
return {
|
||||||
|
url: buildUrl(scope, `/documents/${enc(documentId)}/download`),
|
||||||
|
headers: headers(scope.accessToken),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateDocumentProfile — the ONE write path (behind an explicit, gated action):
|
||||||
|
// PATCH a document's profile fields (e.g. write an AI summary into `comment`).
|
||||||
|
// Never touches content. iManage nests the profile under a `data` key.
|
||||||
|
export function updateDocumentProfile(
|
||||||
|
scope: Scope,
|
||||||
|
documentId: string,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
): PreparedWrite {
|
||||||
|
return {
|
||||||
|
method: 'PATCH',
|
||||||
|
url: buildUrl(scope, `/documents/${enc(documentId)}`),
|
||||||
|
headers: jsonHeaders(scope.accessToken),
|
||||||
|
body: JSON.stringify({ data: patch }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Search ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// search — full-text / profile search for documents across the library. iManage's
|
||||||
|
// document search takes the query as `q` (anywhere) plus pagination; parseSearch
|
||||||
|
// reads the result set (same document shape as a folder listing). Project-wide
|
||||||
|
// find used by the search-and-synthesize action.
|
||||||
|
export function search(scope: Scope, query: string, page?: Page): PreparedGet {
|
||||||
|
return {
|
||||||
|
url: buildUrl(scope, '/documents/search', { q: query, ...pageParams(page) }),
|
||||||
|
headers: headers(scope.accessToken),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Response envelope + paging -------------------------------------------
|
||||||
|
//
|
||||||
|
// iManage Work API v2 wraps its payload under a top-level `data` key and reports
|
||||||
|
// paging in the body (`total_count`/`count` + an `overflow` flag or a `next`
|
||||||
|
// cursor). We normalize both at this boundary so nothing downstream touches the
|
||||||
|
// wire, and tolerate bare/undecorated shapes defensively.
|
||||||
|
|
||||||
|
// unwrap returns the `data` payload of an iManage envelope, or the value itself
|
||||||
|
// when it is already unwrapped. Pure.
|
||||||
|
export function unwrap(body: any): any {
|
||||||
|
if (body && typeof body === 'object' && 'data' in body) return body.data;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// asArray coerces an unwrapped payload to an array (iManage list responses are
|
||||||
|
// `{ data: [...] }`; a stray object or null becomes []).
|
||||||
|
function asArray(body: any): any[] {
|
||||||
|
const d = unwrap(body);
|
||||||
|
return Array.isArray(d) ? d : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The parsed paging state of a list/search response: the total available (when the
|
||||||
|
// server reports it), and whether more pages exist. `hasMore` reads iManage's
|
||||||
|
// `overflow`/`next` signal, falling back to offset+returned<total.
|
||||||
|
export interface Paging {
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePaging(body: any, returned: number, offset = 0): Paging {
|
||||||
|
const totalRaw = body?.total_count ?? body?.count ?? body?.total;
|
||||||
|
const total = Number.isFinite(Number(totalRaw)) && Number(totalRaw) >= 0 ? Number(totalRaw) : 0;
|
||||||
|
const overflow = body?.overflow === true || body?.data?.overflow === true;
|
||||||
|
const hasNext = typeof body?.next === 'string' && body.next.length > 0;
|
||||||
|
const hasMore = overflow || hasNext || (total > 0 && offset + returned < total);
|
||||||
|
return { total, hasMore };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Typed records --------------------------------------------------------
|
||||||
|
|
||||||
|
// One library as it appears in listLibraries.
|
||||||
|
export interface Library {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLibraries(data: any): Library[] {
|
||||||
|
return asArray(data)
|
||||||
|
.map((l: any) => ({ id: String(l?.id ?? ''), name: String(l?.name ?? l?.id ?? '') }))
|
||||||
|
.filter((l: Library) => l.id.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One workspace (matter container) profile.
|
||||||
|
export interface Workspace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
client: string;
|
||||||
|
matter: string;
|
||||||
|
database: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkspace(w: any): Workspace {
|
||||||
|
return {
|
||||||
|
id: String(w?.id ?? ''),
|
||||||
|
name: String(w?.name ?? ''),
|
||||||
|
description: String(w?.description ?? ''),
|
||||||
|
client: String(w?.client ?? w?.custom1 ?? ''),
|
||||||
|
matter: String(w?.matter ?? w?.custom2 ?? ''),
|
||||||
|
database: String(w?.database ?? w?.wsdb ?? ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkspaces(data: any): Workspace[] {
|
||||||
|
return asArray(data).map(parseWorkspace).filter((w: Workspace) => w.id.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkspaceDetail(data: any): Workspace {
|
||||||
|
return parseWorkspace(unwrap(data) ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One document entry as surfaced from a listing/search/profile — the metadata we
|
||||||
|
// render into context. `id` is iManage's opaque document id.
|
||||||
|
export interface DocumentEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
extension: string;
|
||||||
|
size: number;
|
||||||
|
author: string;
|
||||||
|
class: string;
|
||||||
|
type: string;
|
||||||
|
version: string | number;
|
||||||
|
editDate: string;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDocumentRow decides whether a `/children` row is a document (vs a folder).
|
||||||
|
// iManage marks folders with wstype/type `folder`/`workspace`; a document is marked
|
||||||
|
// `document` or (untyped) carries an extension / nested document object. An untyped
|
||||||
|
// row with neither is treated as a folder (a container), never guessed as a doc.
|
||||||
|
function isDocumentRow(r: any): boolean {
|
||||||
|
const t = String(r?.type ?? r?.wstype ?? '').toLowerCase();
|
||||||
|
if (t === 'document') return true;
|
||||||
|
if (t === 'folder' || t === 'workspace') return false;
|
||||||
|
return r?.extension !== undefined || (r?.document && typeof r.document === 'object');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDocumentRow(r: any): DocumentEntry {
|
||||||
|
const d = r?.document && typeof r.document === 'object' ? r.document : r;
|
||||||
|
return {
|
||||||
|
id: String(d?.id ?? ''),
|
||||||
|
name: String(d?.name ?? d?.document_name ?? ''),
|
||||||
|
extension: String(d?.extension ?? '').toLowerCase(),
|
||||||
|
size: Number(d?.size ?? d?.file_size ?? 0) || 0,
|
||||||
|
author: String(d?.author_description ?? d?.author ?? ''),
|
||||||
|
class: String(d?.class_description ?? d?.class ?? ''),
|
||||||
|
// `type_description` only: the raw `type` in a /children row is the folder-vs-
|
||||||
|
// document discriminator (isDocumentRow), NOT the document's profile type.
|
||||||
|
type: String(d?.type_description ?? ''),
|
||||||
|
version: d?.version ?? '',
|
||||||
|
editDate: String(d?.edit_date ?? d?.edit_profile_date ?? d?.update_date ?? ''),
|
||||||
|
comment: String(d?.comment ?? ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDocument reads a getDocument body (envelope-wrapped) into a DocumentEntry.
|
||||||
|
export function parseDocument(data: any): DocumentEntry {
|
||||||
|
return parseDocumentRow(unwrap(data) ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One folder entry from a `/children` listing.
|
||||||
|
export interface FolderEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFolderRow(r: any): FolderEntry {
|
||||||
|
return { id: String(r?.id ?? ''), name: String(r?.name ?? '') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The split contents of a container: its subfolders and its documents. iManage's
|
||||||
|
// `/children` returns both in one mixed array; we separate them so the context
|
||||||
|
// assembly and the picker treat each uniformly.
|
||||||
|
export interface FolderChildren {
|
||||||
|
folders: FolderEntry[];
|
||||||
|
documents: DocumentEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFolderChildren(data: any): FolderChildren {
|
||||||
|
const rows = asArray(data);
|
||||||
|
const folders: FolderEntry[] = [];
|
||||||
|
const documents: DocumentEntry[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
if (isDocumentRow(r)) {
|
||||||
|
const d = parseDocumentRow(r);
|
||||||
|
if (d.id.length > 0) documents.push(d);
|
||||||
|
} else {
|
||||||
|
const f = parseFolderRow(r);
|
||||||
|
if (f.id.length > 0) folders.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { folders, documents };
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSearchResults reads a document-search body into DocumentEntry rows (the
|
||||||
|
// search result set is the same document shape as a listing).
|
||||||
|
export function parseSearchResults(data: any): DocumentEntry[] {
|
||||||
|
return asArray(data)
|
||||||
|
.map(parseDocumentRow)
|
||||||
|
.filter((d: DocumentEntry) => d.id.length > 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// iManage Work OAuth 2.0 (Authorization Code Grant) — pure request shaping. The
|
||||||
|
// client secret is held by the server (server.ts) and never reaches the browser;
|
||||||
|
// these functions build the exact URL/body of each OAuth call so the wire shape is
|
||||||
|
// unit-testable without a network round-trip. The token exchange and refresh are
|
||||||
|
// each one fetch in the server.
|
||||||
|
//
|
||||||
|
// iManage's OAuth2 endpoints live on the customer's Control Center host:
|
||||||
|
// authorize: https://{host}/auth/oauth2/authorize
|
||||||
|
// token: https://{host}/auth/oauth2/token
|
||||||
|
// The token endpoint takes application/x-www-form-urlencoded with client_id +
|
||||||
|
// client_secret IN THE BODY (not HTTP Basic) — the standard OAuth2 web-server
|
||||||
|
// shape. See the iManage Control Center OAuth2 guide.
|
||||||
|
|
||||||
|
import { hosts } from './config.js';
|
||||||
|
|
||||||
|
// authorizeUrl is where the install flow sends the user's browser to grant the
|
||||||
|
// app. `state` is the CSRF token the server generates and re-checks on callback.
|
||||||
|
// iManage takes response_type=code + client_id + redirect_uri; scope is optional
|
||||||
|
// (governed by the app registration + the user's Work permissions), so it is only
|
||||||
|
// appended when non-empty.
|
||||||
|
export function authorizeUrl(args: {
|
||||||
|
host: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
scopes?: readonly string[];
|
||||||
|
state: string;
|
||||||
|
}): string {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: args.clientId,
|
||||||
|
redirect_uri: args.redirectUri,
|
||||||
|
state: args.state,
|
||||||
|
};
|
||||||
|
if (args.scopes && args.scopes.length > 0) params.scope = args.scopes.join(' ');
|
||||||
|
const q = new URLSearchParams(params);
|
||||||
|
return `${hosts(args.host).auth}/auth/oauth2/authorize?${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A prepared HTTP request: the pieces a single fetch needs. Pure output so a test
|
||||||
|
// asserts the form body without opening a socket. iManage's token endpoint takes
|
||||||
|
// application/x-www-form-urlencoded.
|
||||||
|
export interface PreparedRequest {
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenUrl is the OAuth token endpoint for a host.
|
||||||
|
export function tokenUrl(host: string): string {
|
||||||
|
return `${hosts(host).auth}/auth/oauth2/token`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenExchange builds the code→token request against the host's
|
||||||
|
// /auth/oauth2/token. grant_type=authorization_code with the code, the app's
|
||||||
|
// credentials, and the SAME redirect_uri that was used on /authorize (iManage
|
||||||
|
// requires it to match). The secret rides only in this server-side body.
|
||||||
|
export function tokenExchange(args: {
|
||||||
|
host: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
code: string;
|
||||||
|
redirectUri: string;
|
||||||
|
}): PreparedRequest {
|
||||||
|
return {
|
||||||
|
url: tokenUrl(args.host),
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: args.code,
|
||||||
|
client_id: args.clientId,
|
||||||
|
client_secret: args.clientSecret,
|
||||||
|
redirect_uri: args.redirectUri,
|
||||||
|
}).toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshExchange builds the refresh-token→token request. iManage access tokens
|
||||||
|
// are short-lived; the refresh may return a new refresh token, so the server
|
||||||
|
// stores whatever refresh_token comes back after every refresh (defensive against
|
||||||
|
// rotation).
|
||||||
|
export function refreshExchange(args: {
|
||||||
|
host: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}): PreparedRequest {
|
||||||
|
return {
|
||||||
|
url: tokenUrl(args.host),
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: args.refreshToken,
|
||||||
|
client_id: args.clientId,
|
||||||
|
client_secret: args.clientSecret,
|
||||||
|
}).toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token response we care about. iManage returns access_token (+ refresh_token,
|
||||||
|
// expires_in, token_type, scope). parseTokenResponse validates the one field we
|
||||||
|
// must have and surfaces iManage's own error otherwise.
|
||||||
|
export interface ImanageTokenSet {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
token_type?: string;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTokenResponse(data: any): ImanageTokenSet {
|
||||||
|
if (!data || typeof data !== 'object') throw new Error('empty token response');
|
||||||
|
if (data.error) {
|
||||||
|
const reason = data.error_description || data.message || data.error;
|
||||||
|
throw new Error(`iManage OAuth error: ${reason}`);
|
||||||
|
}
|
||||||
|
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
|
||||||
|
throw new Error('iManage OAuth response missing access_token');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
access_token: data.access_token,
|
||||||
|
refresh_token: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
|
||||||
|
expires_in: typeof data.expires_in === 'number' ? data.expires_in : undefined,
|
||||||
|
token_type: typeof data.token_type === 'string' ? data.token_type : undefined,
|
||||||
|
scope: typeof data.scope === 'string' ? data.scope : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenExpiresAt computes the absolute epoch-seconds expiry from a token set,
|
||||||
|
// applying a safety skew so a request is never sent with a token about to expire
|
||||||
|
// mid-flight. iManage does not return an issued-at, so the base is the wall-clock
|
||||||
|
// `now` at which the token was minted (the caller passes it). Pure — testable.
|
||||||
|
export function tokenExpiresAt(token: ImanageTokenSet, mintedAt: number, skewSeconds = 60): number {
|
||||||
|
const ttl = typeof token.expires_in === 'number' ? token.expires_in : 0;
|
||||||
|
return mintedAt + ttl - skewSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// isExpired reports whether a token minted at `mintedAt` is at/past its (skew-
|
||||||
|
// adjusted) expiry as of `now`. The server checks this before each API call and
|
||||||
|
// refreshes when true.
|
||||||
|
export function isExpired(
|
||||||
|
token: ImanageTokenSet,
|
||||||
|
mintedAt: number,
|
||||||
|
now: number,
|
||||||
|
skewSeconds = 60,
|
||||||
|
): boolean {
|
||||||
|
return now >= tokenExpiresAt(token, mintedAt, skewSeconds);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Hanzo AI for iManage</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<span class="title">Hanzo AI</span>
|
||||||
|
<select id="model" aria-label="Model"></select>
|
||||||
|
<span class="host">iManage Work</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="row scope">
|
||||||
|
<input id="customer" placeholder="Customer id" aria-label="iManage customer id" />
|
||||||
|
<input id="library" placeholder="Library id" aria-label="iManage library id" />
|
||||||
|
<button id="browse" class="secondary">Browse</button>
|
||||||
|
</div>
|
||||||
|
<div class="row scope">
|
||||||
|
<select id="workspace" aria-label="Workspace"></select>
|
||||||
|
<select id="document" aria-label="Document"></select>
|
||||||
|
<button id="load" class="secondary">Load doc</button>
|
||||||
|
</div>
|
||||||
|
<div class="row scope">
|
||||||
|
<input id="search" placeholder="Search the library for documents…" aria-label="Document search" />
|
||||||
|
<button id="searchbtn" class="secondary">Search</button>
|
||||||
|
</div>
|
||||||
|
<div class="records" id="records"></div>
|
||||||
|
|
||||||
|
<!-- One-click actions over the loaded document / result set. Chips built from the catalog. -->
|
||||||
|
<div class="chips" id="chips"></div>
|
||||||
|
|
||||||
|
<label for="prompt">Ask about this matter</label>
|
||||||
|
<textarea id="prompt" placeholder="e.g. What is the governing law and termination notice period? · Does this NDA survive termination? · Summarize the parties' obligations."></textarea>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button id="run">Ask Hanzo</button>
|
||||||
|
<button id="stop" class="secondary" disabled>Stop</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button id="save" class="secondary" disabled title="Load a document to save a comment to">Save to comment</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Hanzo API key</summary>
|
||||||
|
<div class="row">
|
||||||
|
<input id="apikey" type="password" placeholder="hk-…" autocomplete="off" />
|
||||||
|
<button id="savekey" class="secondary">Save</button>
|
||||||
|
</div>
|
||||||
|
<div id="authhint" class="hint"></div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
|
||||||
|
<label for="output">Result</label>
|
||||||
|
<textarea id="output" placeholder="Hanzo's analysis appears here."></textarea>
|
||||||
|
|
||||||
|
<footer>Routed through api.hanzo.ai · grounded in your iManage document records · confidential — informational document review, not legal advice.</footer>
|
||||||
|
|
||||||
|
<script type="module" src="__ENTRY__"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Public surface of @hanzo/imanage: the pure, host-agnostic modules. The browser
|
||||||
|
// panel (app.ts) and the Node service (server.ts) are entry points, not re-exported
|
||||||
|
// here. A consumer embedding the document-analysis pipeline in their own service
|
||||||
|
// imports from here.
|
||||||
|
|
||||||
|
export * from './config.js';
|
||||||
|
export * from './imanage-oauth.js';
|
||||||
|
export * from './imanage-api.js';
|
||||||
|
export * from './documents.js';
|
||||||
|
export * from './hanzo.js';
|
||||||
|
export * from './actions.js';
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Browser-side panel helpers — PURE where it counts (launch-context parsing +
|
||||||
|
// proxy-request shaping), so the impure DOM wiring in app.ts stays thin and the
|
||||||
|
// logic is unit-tested. The panel reaches iManage ONLY through the server proxy
|
||||||
|
// (same-origin /proxy/*), which injects the OAuth token (X-Auth-Token) and
|
||||||
|
// refreshes it; the browser never holds an iManage token.
|
||||||
|
//
|
||||||
|
// The customer + library scope lives in the PATH the browser builds (iManage's
|
||||||
|
// paths are /customers/{cid}/libraries/{lib}/...), so the proxy simply prepends the
|
||||||
|
// Work API base and forwards. libBase + enc are reused from imanage-api so the
|
||||||
|
// panel and the server-side wrappers build the exact same relative paths.
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseWorkspaces,
|
||||||
|
parseWorkspaceDetail,
|
||||||
|
parseFolderChildren,
|
||||||
|
parseDocument,
|
||||||
|
parseSearchResults,
|
||||||
|
parsePaging,
|
||||||
|
pageParams,
|
||||||
|
libBase,
|
||||||
|
enc,
|
||||||
|
type Workspace,
|
||||||
|
type DocumentEntry,
|
||||||
|
type FolderChildren,
|
||||||
|
type Paging,
|
||||||
|
type Page,
|
||||||
|
} from './imanage-api.js';
|
||||||
|
import { extractText, type ExtractedText } from './documents.js';
|
||||||
|
|
||||||
|
// The iManage launch context: when a linked/embedded app opens from Work it can
|
||||||
|
// pass the customer, library, and (optionally) a workspace or document in the URL
|
||||||
|
// query. The panel reads them so it opens already scoped. All optional — without
|
||||||
|
// them the user enters the customer/library and picks from the workspace list.
|
||||||
|
export interface LaunchContext {
|
||||||
|
customerId: string;
|
||||||
|
libraryId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
documentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLaunchContext reads the scope ids from a location search string, accepting
|
||||||
|
// snake_case and camelCase keys. Pure — unit-tested with plain strings.
|
||||||
|
export function parseLaunchContext(search: string): LaunchContext {
|
||||||
|
const q = new URLSearchParams(search);
|
||||||
|
const get = (a: string, b: string) => q.get(a) ?? q.get(b) ?? '';
|
||||||
|
return {
|
||||||
|
customerId: get('customer_id', 'customerId'),
|
||||||
|
libraryId: get('library_id', 'libraryId'),
|
||||||
|
workspaceId: get('workspace_id', 'workspaceId'),
|
||||||
|
documentId: get('document_id', 'documentId'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The panel's iManage client: reads through the same-origin server proxy. Every
|
||||||
|
// call builds a customer/library-scoped path and rides the session cookie
|
||||||
|
// (credentials: 'include'). Pure over an injected fetch + base, so request shaping
|
||||||
|
// is unit-testable.
|
||||||
|
export interface ProxyClient {
|
||||||
|
listWorkspaces(page?: Page): Promise<{ items: Workspace[]; paging: Paging }>;
|
||||||
|
getWorkspace(workspaceId: string): Promise<Workspace>;
|
||||||
|
listFolderChildren(folderId: string, page?: Page): Promise<{ items: FolderChildren; paging: Paging }>;
|
||||||
|
getDocument(documentId: string): Promise<DocumentEntry>;
|
||||||
|
getDocumentContent(documentId: string): Promise<ExtractedText>;
|
||||||
|
search(query: string, page?: Page): Promise<{ items: DocumentEntry[]; paging: Paging }>;
|
||||||
|
updateDocumentProfile(documentId: string, patch: Record<string, unknown>): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyClientOptions {
|
||||||
|
/** Customer + library scope — woven into every request path. */
|
||||||
|
customerId: string;
|
||||||
|
libraryId: string;
|
||||||
|
/** Proxy base (defaults to same-origin ''). The REST path is appended after /proxy. */
|
||||||
|
base?: string;
|
||||||
|
/** Injected fetch (tests). Defaults to global fetch. */
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyUrl builds a same-origin proxy URL for a REST path + query. Normalizes the
|
||||||
|
// base (drops a trailing slash) so this is the ONE place base+path joining happens.
|
||||||
|
// Kept small and pure so tests assert the exact shape
|
||||||
|
// (…/proxy/customers/1/libraries/ACTIVE_US/workspaces?limit=…).
|
||||||
|
export function proxyUrl(base: string, restPath: string, query: Record<string, string> = {}): string {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== '') q.set(k, v);
|
||||||
|
const qs = q.toString();
|
||||||
|
return `${base.replace(/\/+$/, '')}/proxy${restPath}${qs ? `?${qs}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// createProxyClient returns a ProxyClient bound to a customer/library scope. The
|
||||||
|
// cookie credentials are attached once here so every method stays a one-liner and
|
||||||
|
// the scoping is impossible to forget.
|
||||||
|
export function createProxyClient(opts: ProxyClientOptions): ProxyClient {
|
||||||
|
const base = opts.base ?? ''; // proxyUrl normalizes the trailing slash
|
||||||
|
const doFetch = opts.fetch ?? fetch;
|
||||||
|
const scope = { customerId: opts.customerId, libraryId: opts.libraryId };
|
||||||
|
const lib = libBase(scope); // /customers/{cid}/libraries/{lib}
|
||||||
|
|
||||||
|
async function getJson(
|
||||||
|
restPath: string,
|
||||||
|
query: Record<string, string>,
|
||||||
|
offset = 0,
|
||||||
|
): Promise<{ data: any; paging: Paging }> {
|
||||||
|
const resp = await doFetch(proxyUrl(base, restPath, query), { credentials: 'include' });
|
||||||
|
if (!resp.ok) throw new Error(await proxyError(resp));
|
||||||
|
const body = await resp.json();
|
||||||
|
const returned = Array.isArray(body?.data) ? body.data.length : Array.isArray(body) ? body.length : 0;
|
||||||
|
return { data: body, paging: parsePaging(body, returned, offset) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async listWorkspaces(page?: Page) {
|
||||||
|
const { data, paging } = await getJson(`${lib}/workspaces`, pageParams(page), page?.offset ?? 0);
|
||||||
|
return { items: parseWorkspaces(data), paging };
|
||||||
|
},
|
||||||
|
async getWorkspace(workspaceId: string) {
|
||||||
|
const { data } = await getJson(`${lib}/workspaces/${enc(workspaceId)}`, {});
|
||||||
|
return parseWorkspaceDetail(data);
|
||||||
|
},
|
||||||
|
async listFolderChildren(folderId: string, page?: Page) {
|
||||||
|
const { data, paging } = await getJson(
|
||||||
|
`${lib}/folders/${enc(folderId)}/children`,
|
||||||
|
pageParams(page),
|
||||||
|
page?.offset ?? 0,
|
||||||
|
);
|
||||||
|
return { items: parseFolderChildren(data), paging };
|
||||||
|
},
|
||||||
|
async getDocument(documentId: string) {
|
||||||
|
const { data } = await getJson(`${lib}/documents/${enc(documentId)}`, {});
|
||||||
|
return parseDocument(data);
|
||||||
|
},
|
||||||
|
async getDocumentContent(documentId: string) {
|
||||||
|
const resp = await doFetch(proxyUrl(base, `${lib}/documents/${enc(documentId)}/download`), {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await proxyError(resp));
|
||||||
|
return extractText(await resp.text());
|
||||||
|
},
|
||||||
|
async search(query: string, page?: Page) {
|
||||||
|
const { data, paging } = await getJson(
|
||||||
|
`${lib}/documents/search`,
|
||||||
|
{ q: query, ...pageParams(page) },
|
||||||
|
page?.offset ?? 0,
|
||||||
|
);
|
||||||
|
return { items: parseSearchResults(data), paging };
|
||||||
|
},
|
||||||
|
async updateDocumentProfile(documentId: string, patch: Record<string, unknown>) {
|
||||||
|
const resp = await doFetch(proxyUrl(base, `${lib}/documents/${enc(documentId)}`), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ data: patch }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await proxyError(resp));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyError extracts a readable message from a non-2xx proxy response.
|
||||||
|
async function proxyError(resp: Response): Promise<string> {
|
||||||
|
const text = await resp.text().catch(() => '');
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text);
|
||||||
|
return `iManage proxy ${resp.status}: ${j?.error || j?.message || text.slice(0, 200)}`;
|
||||||
|
} catch {
|
||||||
|
return `iManage proxy ${resp.status}: ${text.slice(0, 200) || 'request failed'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// The iManage install + Work-API-proxy service. This is the ONLY place the iManage
|
||||||
|
// client secret and the OAuth tokens exist — the secret is read from the
|
||||||
|
// environment (never bundled, never sent to the browser); tokens are minted by the
|
||||||
|
// OAuth flow and kept server-side, refreshed transparently on expiry. It:
|
||||||
|
//
|
||||||
|
// GET /oauth/install → redirect the user to iManage's OAuth consent
|
||||||
|
// GET /oauth/callback → exchange the code for a token, open a session, and
|
||||||
|
// hand the browser a session cookie
|
||||||
|
// GET /config → the non-secret scoping defaults (customer/library)
|
||||||
|
// for the panel to pre-fill; NO secrets, NO tokens
|
||||||
|
// ALL /proxy/* → forward a browser request to the iManage Work API
|
||||||
|
// with the session's access token (X-Auth-Token),
|
||||||
|
// refreshing it first if it has expired. The browser
|
||||||
|
// never sees the token or the secret.
|
||||||
|
// GET /healthz → readiness
|
||||||
|
//
|
||||||
|
// CONFIDENTIALITY: this service handles privileged legal documents. It NEVER logs
|
||||||
|
// request or response BODIES (document content, profiles, search terms) — only
|
||||||
|
// non-content bookkeeping (a session id, a status code, a target path). Tokens and
|
||||||
|
// the client secret stay server-side.
|
||||||
|
//
|
||||||
|
// It is a dependency-free Node http handler built on the pure modules (config,
|
||||||
|
// imanage-oauth, imanage-api) so it is deployable behind hanzoai/ingress as a small
|
||||||
|
// service at imanage.hanzo.ai.
|
||||||
|
//
|
||||||
|
// node dist/server.js (after build.js bundles it)
|
||||||
|
//
|
||||||
|
// Required environment (see config.readServerConfig):
|
||||||
|
// IMANAGE_CLIENT_ID, IMANAGE_CLIENT_SECRET, IMANAGE_REDIRECT_URI, IMANAGE_HOST
|
||||||
|
// IMANAGE_API_HOST (optional; defaults to IMANAGE_HOST)
|
||||||
|
// IMANAGE_CUSTOMER_ID, IMANAGE_LIBRARY_ID (optional panel defaults)
|
||||||
|
// PORT (default 8793)
|
||||||
|
|
||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { apiBaseUrl, readServerConfig, OAUTH_SCOPES, type ServerConfig } from './config.js';
|
||||||
|
import { AUTH_HEADER } from './imanage-api.js';
|
||||||
|
import {
|
||||||
|
authorizeUrl,
|
||||||
|
tokenExchange,
|
||||||
|
refreshExchange,
|
||||||
|
parseTokenResponse,
|
||||||
|
isExpired,
|
||||||
|
type ImanageTokenSet,
|
||||||
|
} from './imanage-oauth.js';
|
||||||
|
|
||||||
|
// A server-side session: the tokens for one authorized user + when they were
|
||||||
|
// minted (iManage returns no issued-at, so we track wall-clock at mint to compute
|
||||||
|
// expiry). In-memory here keyed by an opaque session id carried in an HttpOnly
|
||||||
|
// cookie; a production deployment persists this to hanzoai/kv (Valkey) so sessions
|
||||||
|
// survive a restart.
|
||||||
|
interface Session {
|
||||||
|
token: ImanageTokenSet;
|
||||||
|
/** Wall-clock epoch seconds when the token was last minted/refreshed. */
|
||||||
|
mintedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, Session>();
|
||||||
|
const SESSION_COOKIE = 'imanage_sid';
|
||||||
|
|
||||||
|
// A pending OAuth `state` (CSRF token) minted at /oauth/install and consumed at
|
||||||
|
// /oauth/callback. A production deployment persists these (Valkey, short TTL); a
|
||||||
|
// Set is enough for a single instance.
|
||||||
|
const pendingStates = new Set<string>();
|
||||||
|
|
||||||
|
function nowSeconds(): number {
|
||||||
|
return Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(res: ServerResponse, status: number, body: unknown): void {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
// log emits ONLY non-content bookkeeping fields. Callers must never pass a request
|
||||||
|
// or response body here — see the confidentiality note at the top of the file.
|
||||||
|
function log(fields: Record<string, unknown>): void {
|
||||||
|
console.log(JSON.stringify(fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionIdFromCookie reads the session id from the Cookie header. Minimal parser —
|
||||||
|
// we only need our one cookie.
|
||||||
|
function sessionIdFromCookie(req: IncomingMessage): string | undefined {
|
||||||
|
const raw = req.headers.cookie;
|
||||||
|
if (!raw) return undefined;
|
||||||
|
for (const part of raw.split(';')) {
|
||||||
|
const [k, ...v] = part.trim().split('=');
|
||||||
|
if (k === SESSION_COOKIE) return decodeURIComponent(v.join('='));
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /oauth/install → 302 to iManage's consent screen. `state` is a fresh CSRF
|
||||||
|
// token re-checked on callback.
|
||||||
|
function handleInstall(cfg: ServerConfig, res: ServerResponse): void {
|
||||||
|
const state = randomUUID();
|
||||||
|
pendingStates.add(state);
|
||||||
|
const url = authorizeUrl({
|
||||||
|
host: cfg.imanageHost,
|
||||||
|
clientId: cfg.imanageClientId,
|
||||||
|
redirectUri: cfg.imanageRedirectUri,
|
||||||
|
scopes: OAUTH_SCOPES,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
res.writeHead(302, { Location: url });
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /oauth/callback?code=…&state=… → verify state, exchange the code for a token
|
||||||
|
// (secret in the server-side body), open a session, and set the cookie.
|
||||||
|
async function handleOAuthCallback(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
if (!code) return json(res, 400, { error: 'missing code' });
|
||||||
|
if (!state || !pendingStates.delete(state)) return json(res, 400, { error: 'invalid state' });
|
||||||
|
|
||||||
|
const exReq = tokenExchange({
|
||||||
|
host: cfg.imanageHost,
|
||||||
|
clientId: cfg.imanageClientId,
|
||||||
|
clientSecret: cfg.imanageClientSecret,
|
||||||
|
code,
|
||||||
|
redirectUri: cfg.imanageRedirectUri,
|
||||||
|
});
|
||||||
|
let token: ImanageTokenSet;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(exReq.url, { method: 'POST', headers: exReq.headers, body: exReq.body });
|
||||||
|
token = parseTokenResponse(await resp.json().catch(() => ({})));
|
||||||
|
} catch (e: any) {
|
||||||
|
return json(res, 400, { error: e?.message || 'token exchange failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = randomUUID();
|
||||||
|
sessions.set(sid, { token, mintedAt: nowSeconds() });
|
||||||
|
log({ msg: 'oauth: session opened', sid });
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Set-Cookie': `${SESSION_COOKIE}=${sid}; HttpOnly; Secure; SameSite=Lax; Path=/`,
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({ ok: true, installed: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /config → the non-secret scoping defaults so a single-tenant panel opens
|
||||||
|
// pre-scoped. Never returns the client secret or any token.
|
||||||
|
function handleConfig(cfg: ServerConfig, res: ServerResponse): void {
|
||||||
|
json(res, 200, { customerId: cfg.customerId, libraryId: cfg.libraryId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshToken returns a session's access token, refreshing first if it has expired.
|
||||||
|
// The refresh may rotate iManage's refresh token, so we store the new set. Throws if
|
||||||
|
// the refresh fails (the caller returns 401 and the user re-installs).
|
||||||
|
async function freshToken(cfg: ServerConfig, sid: string, session: Session): Promise<string> {
|
||||||
|
if (!isExpired(session.token, session.mintedAt, nowSeconds())) return session.token.access_token;
|
||||||
|
const refreshToken = session.token.refresh_token;
|
||||||
|
if (!refreshToken) throw new Error('session expired and no refresh token');
|
||||||
|
const rReq = refreshExchange({
|
||||||
|
host: cfg.imanageHost,
|
||||||
|
clientId: cfg.imanageClientId,
|
||||||
|
clientSecret: cfg.imanageClientSecret,
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
const resp = await fetch(rReq.url, { method: 'POST', headers: rReq.headers, body: rReq.body });
|
||||||
|
const next = parseTokenResponse(await resp.json().catch(() => ({})));
|
||||||
|
// iManage may or may not rotate the refresh token; keep the prior one if absent.
|
||||||
|
if (!next.refresh_token) next.refresh_token = refreshToken;
|
||||||
|
sessions.set(sid, { token: next, mintedAt: nowSeconds() });
|
||||||
|
log({ msg: 'oauth: token refreshed', sid });
|
||||||
|
return next.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALL /proxy/* → forward to the iManage Work API. The browser sends the Work API
|
||||||
|
// path (after /proxy), already customer/library-scoped; the server injects the
|
||||||
|
// access token (X-Auth-Token, never exposed to the browser) and forwards the
|
||||||
|
// method, query, and body. This is the API proxy that keeps the secret + tokens
|
||||||
|
// server-side while letting the panel read workspaces/documents/search and PATCH a
|
||||||
|
// profile. It NEVER logs the forwarded body or the upstream body.
|
||||||
|
async function handleProxy(
|
||||||
|
cfg: ServerConfig,
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
url: URL,
|
||||||
|
): Promise<void> {
|
||||||
|
const sid = sessionIdFromCookie(req);
|
||||||
|
const session = sid ? sessions.get(sid) : undefined;
|
||||||
|
if (!sid || !session) return json(res, 401, { error: 'not authenticated' });
|
||||||
|
|
||||||
|
let accessToken: string;
|
||||||
|
try {
|
||||||
|
accessToken = await freshToken(cfg, sid, session);
|
||||||
|
} catch (e: any) {
|
||||||
|
return json(res, 401, { error: e?.message || 'token refresh failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The path after /proxy is the Work API path relative to the version root, e.g.
|
||||||
|
// /proxy/customers/1/libraries/ACTIVE_US/workspaces → ${apiBase}/customers/…. We
|
||||||
|
// build the target from apiBase so it can only ever reach the iManage host.
|
||||||
|
const restPath = url.pathname.replace(/^\/proxy/, '') || '/';
|
||||||
|
const target = `${apiBaseUrl(cfg.imanageApiHost ?? cfg.imanageHost)}${restPath}${url.search}`;
|
||||||
|
|
||||||
|
const method = req.method || 'GET';
|
||||||
|
const body = method === 'GET' || method === 'HEAD' ? undefined : await readRawBody(req);
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(target, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
[AUTH_HEADER]: accessToken,
|
||||||
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const passthroughType = upstream.headers.get('Content-Type') || 'application/json';
|
||||||
|
const buf = Buffer.from(await upstream.arrayBuffer());
|
||||||
|
res.writeHead(upstream.status, { 'Content-Type': passthroughType });
|
||||||
|
res.end(buf);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Log the target path (no query/body) and the error only — never content.
|
||||||
|
log({ msg: 'proxy: upstream error', error: e?.message, path: restPath });
|
||||||
|
return json(res, 502, { error: 'upstream request failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRawBody collects the raw request bytes as a utf8 string.
|
||||||
|
async function readRawBody(req: IncomingMessage): Promise<string> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const c of req) chunks.push(c as Buffer);
|
||||||
|
return Buffer.concat(chunks).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// createHandler is the request router. Everything is a small handler over the pure
|
||||||
|
// modules. Exported so a test can drive it with a mock req/res if desired.
|
||||||
|
export function createHandler(cfg: ServerConfig) {
|
||||||
|
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||||
|
const url = new URL(req.url || '/', `http://localhost:${cfg.port}`);
|
||||||
|
try {
|
||||||
|
if (req.method === 'GET' && url.pathname === '/oauth/install') return handleInstall(cfg, res);
|
||||||
|
if (req.method === 'GET' && url.pathname === '/oauth/callback') return await handleOAuthCallback(cfg, url, res);
|
||||||
|
if (req.method === 'GET' && url.pathname === '/config') return handleConfig(cfg, res);
|
||||||
|
if (url.pathname === '/proxy' || url.pathname.startsWith('/proxy/')) return await handleProxy(cfg, req, res, url);
|
||||||
|
if (req.method === 'GET' && url.pathname === '/healthz') return json(res, 200, { ok: true });
|
||||||
|
return json(res, 404, { error: 'not found' });
|
||||||
|
} catch (e: any) {
|
||||||
|
log({ msg: 'unhandled error', error: e?.message });
|
||||||
|
return json(res, 500, { error: 'internal error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// main boots the server when run directly. Import-safe: only the direct entry
|
||||||
|
// listens, so tests import the handlers without opening a port.
|
||||||
|
export function main(): void {
|
||||||
|
const cfg = readServerConfig(process.env);
|
||||||
|
const server = createServer(createHandler(cfg));
|
||||||
|
server.listen(cfg.port, () => {
|
||||||
|
log({ msg: 'hanzo imanage service up', port: cfg.port, host: cfg.imanageHost });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--fg: #1a1a1a; --muted: #666; --bg: #fff; --line: #e3e3e3; --accent: #111;
|
||||||
|
--ok: #1a7f37; --warn: #9a6700; --error: #cf222e;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--fg: #eaeaea; --muted: #9a9a9a; --bg: #1e1e1e; --line: #333; --accent: #fff;
|
||||||
|
--ok: #3fb950; --warn: #d29922; --error: #f85149;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: var(--fg); background: var(--bg); margin: 0 auto; padding: 14px;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||||
|
header .title { font-weight: 600; font-size: 15px; }
|
||||||
|
header .host { color: var(--muted); font-size: 12px; margin-left: auto; }
|
||||||
|
label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
|
||||||
|
textarea, select, input {
|
||||||
|
width: 100%; font: inherit; color: var(--fg); background: var(--bg);
|
||||||
|
border: 1px solid var(--line); border-radius: 6px; padding: 8px;
|
||||||
|
}
|
||||||
|
textarea#prompt { min-height: 56px; resize: vertical; }
|
||||||
|
textarea#output { min-height: 220px; resize: vertical; }
|
||||||
|
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||||
|
.row.scope { margin-top: 4px; }
|
||||||
|
.row.scope input#customer, .row.scope input#library { max-width: 140px; }
|
||||||
|
button {
|
||||||
|
font: inherit; border: 1px solid var(--line); background: var(--accent);
|
||||||
|
color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
button.secondary { background: transparent; color: var(--fg); }
|
||||||
|
button:disabled { opacity: .5; cursor: default; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.records { font-size: 11px; color: var(--muted); margin-top: 6px; min-height: 14px; }
|
||||||
|
.status { min-height: 18px; font-size: 12px; margin-top: 10px; color: var(--muted); }
|
||||||
|
.status.ok { color: var(--ok); } .status.warn { color: var(--warn); } .status.error { color: var(--error); }
|
||||||
|
.hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||||
|
footer { margin-top: 12px; font-size: 11px; color: var(--muted); }
|
||||||
|
select#model { width: auto; max-width: 150px; padding: 4px 8px; font-size: 12px; }
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||||
|
.chip {
|
||||||
|
padding: 5px 10px; border-radius: 999px; font-size: 13px;
|
||||||
|
background: transparent; color: var(--fg); border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.chip:hover:not(:disabled) { border-color: var(--accent); }
|
||||||
|
details summary { cursor: pointer; font-size: 12px; color: var(--muted); margin-top: 10px; }
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { AiClient, ChatCompletion, ChatCompletionCreateParams } from '@hanzo/ai';
|
||||||
|
import { ACTIONS, actionList, actionPrompt, isActionId, runAction } from '../src/actions.js';
|
||||||
|
import { buildContext } from '../src/documents.js';
|
||||||
|
|
||||||
|
const ctx = buildContext([{ title: 'Document', blocks: ['Document: NDA — mutual, 3-year term'] }]);
|
||||||
|
|
||||||
|
function mockClient(text: string) {
|
||||||
|
const reply: ChatCompletion = {
|
||||||
|
id: 'c', object: 'chat.completion', created: 0, model: 'zen5',
|
||||||
|
choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
|
||||||
|
};
|
||||||
|
const create = vi.fn(async (_p: ChatCompletionCreateParams) => reply);
|
||||||
|
return { client: { chat: { completions: { create } }, models: { list: vi.fn() } } as unknown as AiClient, create };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('actions: catalog', () => {
|
||||||
|
it('exposes exactly the four documented actions', () => {
|
||||||
|
expect(Object.keys(ACTIONS).sort()).toEqual(
|
||||||
|
['compareDocuments', 'extractClauses', 'summarizeDocument', 'synthesizeMatter'].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every action has a non-empty label and a substantial prompt', () => {
|
||||||
|
for (const [id, a] of Object.entries(ACTIONS)) {
|
||||||
|
expect(a.label.length, id).toBeGreaterThan(0);
|
||||||
|
expect(a.prompt.length, id).toBeGreaterThan(20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('actionList mirrors the catalog order and shape', () => {
|
||||||
|
const list = actionList();
|
||||||
|
expect(list.map((a) => a.id)).toEqual(Object.keys(ACTIONS));
|
||||||
|
expect(list[0]).toEqual({ id: 'summarizeDocument', label: ACTIONS.summarizeDocument.label });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('actions: prompt intent', () => {
|
||||||
|
it('summarizeDocument grounds in the text and handles profile-only', () => {
|
||||||
|
const p = actionPrompt('summarizeDocument');
|
||||||
|
expect(p).toMatch(/summar/i);
|
||||||
|
expect(p).toMatch(/parties|obligations|terms/i);
|
||||||
|
expect(p).toMatch(/metadata|profile/i);
|
||||||
|
});
|
||||||
|
it('extractClauses asks for parties, dates, clauses (contract review)', () => {
|
||||||
|
const p = actionPrompt('extractClauses');
|
||||||
|
expect(p).toMatch(/parties/i);
|
||||||
|
expect(p).toMatch(/dates/i);
|
||||||
|
expect(p).toMatch(/clauses/i);
|
||||||
|
expect(p).toMatch(/governing law|indemnity|termination/i);
|
||||||
|
expect(p).toMatch(/never infer|None found/i);
|
||||||
|
});
|
||||||
|
it('synthesizeMatter synthesizes across a set and attributes by document', () => {
|
||||||
|
const p = actionPrompt('synthesizeMatter');
|
||||||
|
expect(p).toMatch(/synthesize/i);
|
||||||
|
expect(p).toMatch(/attribute|by name or number/i);
|
||||||
|
});
|
||||||
|
it('compareDocuments compares differences side by side', () => {
|
||||||
|
const p = actionPrompt('compareDocuments');
|
||||||
|
expect(p).toMatch(/compare/i);
|
||||||
|
expect(p).toMatch(/difference/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('actions: isActionId / actionPrompt guards', () => {
|
||||||
|
it('narrows known ids and rejects unknown', () => {
|
||||||
|
expect(isActionId('summarizeDocument')).toBe(true);
|
||||||
|
expect(isActionId('nope')).toBe(false);
|
||||||
|
});
|
||||||
|
it('actionPrompt throws on an unknown id', () => {
|
||||||
|
expect(() => actionPrompt('nope')).toThrow(/Unknown action/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('actions: runAction', () => {
|
||||||
|
it('routes the resolved prompt through ask (one code path to the model)', async () => {
|
||||||
|
const { client, create } = mockClient('clauses extracted');
|
||||||
|
const out = await runAction('extractClauses', ctx, { workspaceName: 'Acme' }, { client });
|
||||||
|
expect(out).toBe('clauses extracted');
|
||||||
|
const user = String(create.mock.calls[0][0].messages[1].content);
|
||||||
|
expect(user).toContain(ACTIONS.extractClauses.prompt);
|
||||||
|
expect(user).toContain('Document: NDA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects (not synchronously throws) on an unknown id', async () => {
|
||||||
|
await expect(runAction('nope', ctx)).rejects.toThrow(/Unknown action/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
HANZO_API_BASE_URL,
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
APIKEY_STORAGE_KEY,
|
||||||
|
DOCUMENT_CHAR_BUDGET,
|
||||||
|
pickBearer,
|
||||||
|
chatCompletionsURL,
|
||||||
|
modelsURL,
|
||||||
|
hosts,
|
||||||
|
normalizeHost,
|
||||||
|
isHttpUrl,
|
||||||
|
apiBaseUrl,
|
||||||
|
WORK_API_VERSION,
|
||||||
|
readServerConfig,
|
||||||
|
} from '../src/config.js';
|
||||||
|
|
||||||
|
describe('config: gateway constants', () => {
|
||||||
|
it('points at api.hanzo.ai with /v1 paths and never /api/', () => {
|
||||||
|
expect(HANZO_API_BASE_URL).toBe('https://api.hanzo.ai');
|
||||||
|
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
|
||||||
|
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
|
||||||
|
expect(chatCompletionsURL()).not.toContain('/api/');
|
||||||
|
expect(modelsURL()).not.toContain('/api/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to a zen model and a sane budget', () => {
|
||||||
|
expect(DEFAULT_MODEL).toBe('zen5');
|
||||||
|
expect(DOCUMENT_CHAR_BUDGET).toBeGreaterThan(10_000);
|
||||||
|
expect(APIKEY_STORAGE_KEY).toContain('imanage');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('config: pickBearer', () => {
|
||||||
|
it('prefers a pasted key over an oauth token', () => {
|
||||||
|
expect(pickBearer('hk-abc', 'oauth-xyz')).toBe('hk-abc');
|
||||||
|
});
|
||||||
|
it('falls back to the oauth token, trimming whitespace', () => {
|
||||||
|
expect(pickBearer(' ', ' tok ')).toBe('tok');
|
||||||
|
});
|
||||||
|
it('returns empty when both are blank (anonymous)', () => {
|
||||||
|
expect(pickBearer('', '')).toBe('');
|
||||||
|
expect(pickBearer(' ', '')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('config: normalizeHost', () => {
|
||||||
|
it('adds https:// to a bare host', () => {
|
||||||
|
expect(normalizeHost('cloudimanage.com')).toBe('https://cloudimanage.com');
|
||||||
|
});
|
||||||
|
it('keeps an explicit scheme and strips path + trailing slash', () => {
|
||||||
|
expect(normalizeHost('https://work.firm.com/')).toBe('https://work.firm.com');
|
||||||
|
expect(normalizeHost('https://work.firm.com/api/v2')).toBe('https://work.firm.com');
|
||||||
|
});
|
||||||
|
it('preserves a port', () => {
|
||||||
|
expect(normalizeHost('work.firm.com:8443')).toBe('https://work.firm.com:8443');
|
||||||
|
});
|
||||||
|
it('returns empty for an empty host', () => {
|
||||||
|
expect(normalizeHost('')).toBe('');
|
||||||
|
expect(normalizeHost(' ')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('config: isHttpUrl', () => {
|
||||||
|
it('accepts bare hosts and full urls', () => {
|
||||||
|
expect(isHttpUrl('cloudimanage.com')).toBe(true);
|
||||||
|
expect(isHttpUrl('https://work.firm.com')).toBe(true);
|
||||||
|
expect(isHttpUrl('http://localhost:8793')).toBe(true);
|
||||||
|
});
|
||||||
|
it('rejects empty / nonsense', () => {
|
||||||
|
expect(isHttpUrl(undefined)).toBe(false);
|
||||||
|
expect(isHttpUrl('')).toBe(false);
|
||||||
|
expect(isHttpUrl('ftp://x')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('config: hosts + apiBaseUrl', () => {
|
||||||
|
it('resolves auth + api to the same host by default', () => {
|
||||||
|
const h = hosts('cloudimanage.com');
|
||||||
|
expect(h.auth).toBe('https://cloudimanage.com');
|
||||||
|
expect(h.api).toBe('https://cloudimanage.com');
|
||||||
|
});
|
||||||
|
it('honours a distinct api host', () => {
|
||||||
|
const h = hosts('cc.firm.com', 'work.firm.com');
|
||||||
|
expect(h.auth).toBe('https://cc.firm.com');
|
||||||
|
expect(h.api).toBe('https://work.firm.com');
|
||||||
|
});
|
||||||
|
it('builds the /api/v2 Work API root (iManage own path)', () => {
|
||||||
|
expect(apiBaseUrl('cloudimanage.com')).toBe('https://cloudimanage.com/api/v2');
|
||||||
|
expect(apiBaseUrl('https://work.firm.com/')).toBe('https://work.firm.com/api/v2');
|
||||||
|
expect(WORK_API_VERSION).toBe('v2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('config: readServerConfig', () => {
|
||||||
|
const base = {
|
||||||
|
IMANAGE_CLIENT_ID: 'cid',
|
||||||
|
IMANAGE_CLIENT_SECRET: 'secret',
|
||||||
|
IMANAGE_REDIRECT_URI: 'https://imanage.hanzo.ai/oauth/callback',
|
||||||
|
IMANAGE_HOST: 'https://cloudimanage.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('reads a full config, defaulting port to 8793 and empty scope defaults', () => {
|
||||||
|
const cfg = readServerConfig(base);
|
||||||
|
expect(cfg.imanageClientId).toBe('cid');
|
||||||
|
expect(cfg.imanageClientSecret).toBe('secret');
|
||||||
|
expect(cfg.imanageRedirectUri).toBe('https://imanage.hanzo.ai/oauth/callback');
|
||||||
|
expect(cfg.imanageHost).toBe('https://cloudimanage.com');
|
||||||
|
expect(cfg.imanageApiHost).toBeUndefined();
|
||||||
|
expect(cfg.customerId).toBe('');
|
||||||
|
expect(cfg.libraryId).toBe('');
|
||||||
|
expect(cfg.port).toBe(8793);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a bare host and honours PORT + scope defaults + api host', () => {
|
||||||
|
const cfg = readServerConfig({
|
||||||
|
...base,
|
||||||
|
IMANAGE_HOST: 'cloudimanage.com',
|
||||||
|
IMANAGE_API_HOST: 'work.firm.com',
|
||||||
|
IMANAGE_CUSTOMER_ID: '1',
|
||||||
|
IMANAGE_LIBRARY_ID: 'ACTIVE_US',
|
||||||
|
PORT: '9100',
|
||||||
|
});
|
||||||
|
expect(cfg.imanageHost).toBe('https://cloudimanage.com');
|
||||||
|
expect(cfg.imanageApiHost).toBe('https://work.firm.com');
|
||||||
|
expect(cfg.customerId).toBe('1');
|
||||||
|
expect(cfg.libraryId).toBe('ACTIVE_US');
|
||||||
|
expect(cfg.port).toBe(9100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws listing every missing required secret/host', () => {
|
||||||
|
expect(() => readServerConfig({})).toThrow(/IMANAGE_CLIENT_ID/);
|
||||||
|
expect(() => readServerConfig({})).toThrow(/IMANAGE_CLIENT_SECRET/);
|
||||||
|
expect(() => readServerConfig({})).toThrow(/IMANAGE_REDIRECT_URI/);
|
||||||
|
expect(() => readServerConfig({})).toThrow(/IMANAGE_HOST/);
|
||||||
|
expect(() => readServerConfig({ IMANAGE_CLIENT_ID: 'x' })).toThrow(
|
||||||
|
/IMANAGE_CLIENT_SECRET, IMANAGE_REDIRECT_URI, IMANAGE_HOST/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed host / api host', () => {
|
||||||
|
expect(() => readServerConfig({ ...base, IMANAGE_HOST: 'not a url' })).toThrow(/IMANAGE_HOST is not a valid/);
|
||||||
|
expect(() => readServerConfig({ ...base, IMANAGE_API_HOST: 'not a host' })).toThrow(/IMANAGE_API_HOST is not a valid/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
looksBinary,
|
||||||
|
extractText,
|
||||||
|
renderDocumentMeta,
|
||||||
|
renderWorkspace,
|
||||||
|
renderDocumentLine,
|
||||||
|
renderFolderLine,
|
||||||
|
contentBlocks,
|
||||||
|
buildContext,
|
||||||
|
contextNote,
|
||||||
|
documentSection,
|
||||||
|
documentIndexSection,
|
||||||
|
folderSection,
|
||||||
|
workspaceSection,
|
||||||
|
searchResultSection,
|
||||||
|
} from '../src/documents.js';
|
||||||
|
import type { DocumentEntry, Workspace, FolderEntry } from '../src/imanage-api.js';
|
||||||
|
|
||||||
|
const doc: DocumentEntry = {
|
||||||
|
id: 'ACTIVE_US!4567.1',
|
||||||
|
name: 'Master Services Agreement',
|
||||||
|
extension: 'docx',
|
||||||
|
size: 48213,
|
||||||
|
author: 'Jane Smith',
|
||||||
|
class: 'Agreement',
|
||||||
|
type: 'Document',
|
||||||
|
version: 1,
|
||||||
|
editDate: '2026-06-20',
|
||||||
|
comment: 'Final executed copy',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Binary content is built from char codes so no literal control bytes live in the
|
||||||
|
// source. NUL is a reliable binary tell; CONTROLS is a run of C0 control chars.
|
||||||
|
const NUL = String.fromCharCode(0);
|
||||||
|
const CONTROLS = Array.from({ length: 12 }, (_, i) => String.fromCharCode(i + 1)).join('');
|
||||||
|
|
||||||
|
describe('documents: looksBinary', () => {
|
||||||
|
it('is false for plain text (incl. tabs and newlines)', () => {
|
||||||
|
expect(looksBinary('This is a\tcontract.\nGoverning law: NY.')).toBe(false);
|
||||||
|
expect(looksBinary('')).toBe(false);
|
||||||
|
});
|
||||||
|
it('is true for content with a NUL byte', () => {
|
||||||
|
expect(looksBinary('PK' + NUL + 'native bytes')).toBe(true);
|
||||||
|
});
|
||||||
|
it('is true for a high share of control characters', () => {
|
||||||
|
expect(looksBinary('abc' + CONTROLS)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('documents: extractText', () => {
|
||||||
|
it('returns cleaned text for plain content', () => {
|
||||||
|
const r = extractText(' Agreement\r\ntext. ', 'MSA.txt');
|
||||||
|
expect(r.extracted).toBe(true);
|
||||||
|
expect(r.text).toBe('Agreement\ntext.');
|
||||||
|
});
|
||||||
|
it('reports binary content as not extractable (OCR out of scope)', () => {
|
||||||
|
const r = extractText('PK' + NUL + 'native docx bytes', 'MSA.docx');
|
||||||
|
expect(r.extracted).toBe(false);
|
||||||
|
expect(r.text).toBe('');
|
||||||
|
expect(r.reason).toMatch(/out of scope|binary/i);
|
||||||
|
expect(r.reason).toContain('MSA.docx');
|
||||||
|
});
|
||||||
|
it('reports empty content honestly', () => {
|
||||||
|
const r = extractText(' ');
|
||||||
|
expect(r.extracted).toBe(false);
|
||||||
|
expect(r.reason).toMatch(/no extractable text/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('documents: record rendering', () => {
|
||||||
|
it('renders a document profile with meta and comment', () => {
|
||||||
|
const t = renderDocumentMeta(doc);
|
||||||
|
expect(t).toContain('Document: Master Services Agreement');
|
||||||
|
expect(t).toContain('Type: .docx');
|
||||||
|
expect(t).toContain('Class: Agreement');
|
||||||
|
expect(t).toContain('Author: Jane Smith');
|
||||||
|
expect(t).toContain('Version: 1');
|
||||||
|
expect(t).toContain('Comment: Final executed copy');
|
||||||
|
});
|
||||||
|
it('renders a workspace, a document line, and a folder line compactly', () => {
|
||||||
|
const ws: Workspace = { id: 'W1', name: 'Acme / Falcon', description: 'M&A', client: 'Acme', matter: '2026-0042', database: 'ACTIVE_US' };
|
||||||
|
expect(renderWorkspace(ws)).toContain('Workspace: Acme / Falcon');
|
||||||
|
expect(renderWorkspace(ws)).toContain('Client: Acme · Matter: 2026-0042');
|
||||||
|
expect(renderWorkspace(ws)).toContain('M&A');
|
||||||
|
|
||||||
|
expect(renderDocumentLine(doc)).toBe('Doc: Master Services Agreement.docx — Jane Smith (2026-06-20)');
|
||||||
|
|
||||||
|
const f: FolderEntry = { id: 'F1', name: 'Contracts' };
|
||||||
|
expect(renderFolderLine(f)).toBe('Folder: Contracts');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('documents: contentBlocks', () => {
|
||||||
|
it('splits on blank lines and trims, dropping empties', () => {
|
||||||
|
expect(contentBlocks('Para 1.\n\n\nPara 2.\n\n ')).toEqual(['Para 1.', 'Para 2.']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('documents: buildContext (the windowing algorithm)', () => {
|
||||||
|
it('returns empty for no blocks', () => {
|
||||||
|
const ctx = buildContext([{ title: 'Document', blocks: [] }]);
|
||||||
|
expect(ctx).toEqual({ text: '', totalBlocks: 0, includedBlocks: 0, truncated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes all blocks under section headers when within budget', () => {
|
||||||
|
const ctx = buildContext([
|
||||||
|
{ title: 'Profile', blocks: ['a', 'b'] },
|
||||||
|
{ title: 'Text', blocks: ['c'] },
|
||||||
|
]);
|
||||||
|
expect(ctx.totalBlocks).toBe(3);
|
||||||
|
expect(ctx.includedBlocks).toBe(3);
|
||||||
|
expect(ctx.truncated).toBe(false);
|
||||||
|
expect(ctx.text).toContain('## Profile');
|
||||||
|
expect(ctx.text).toContain('## Text');
|
||||||
|
expect(ctx.text.indexOf('## Profile')).toBeLessThan(ctx.text.indexOf('## Text'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('windows in order and reports truncation when the budget is exceeded', () => {
|
||||||
|
const blocks = ['x'.repeat(30), 'y'.repeat(30), 'z'.repeat(30)];
|
||||||
|
const ctx = buildContext([{ title: 'Text', blocks }], 60);
|
||||||
|
expect(ctx.totalBlocks).toBe(3);
|
||||||
|
expect(ctx.includedBlocks).toBeLessThan(3);
|
||||||
|
expect(ctx.truncated).toBe(true);
|
||||||
|
expect(ctx.text).toContain('xxx');
|
||||||
|
expect(ctx.text).not.toContain('zzz');
|
||||||
|
expect(ctx.text.length).toBeLessThanOrEqual(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always includes at least the first block, hard-cutting it if it alone overflows', () => {
|
||||||
|
const huge = 'q'.repeat(500);
|
||||||
|
const ctx = buildContext([{ title: 'Text', blocks: [huge, 'next'] }], 50);
|
||||||
|
expect(ctx.includedBlocks).toBe(1);
|
||||||
|
expect(ctx.truncated).toBe(true);
|
||||||
|
expect(ctx.text.length).toBe(50);
|
||||||
|
expect(ctx.text.startsWith('## Text')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips empty sections without charging a header', () => {
|
||||||
|
const ctx = buildContext([
|
||||||
|
{ title: 'Empty', blocks: [] },
|
||||||
|
{ title: 'Text', blocks: ['a'] },
|
||||||
|
]);
|
||||||
|
expect(ctx.text).not.toContain('## Empty');
|
||||||
|
expect(ctx.text).toContain('## Text');
|
||||||
|
expect(ctx.includedBlocks).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('documents: contextNote', () => {
|
||||||
|
it('is honest about no records', () => {
|
||||||
|
expect(contextNote({ text: '', totalBlocks: 0, includedBlocks: 0, truncated: false })).toMatch(/No document/);
|
||||||
|
});
|
||||||
|
it('reports full coverage', () => {
|
||||||
|
expect(contextNote({ text: 'x', totalBlocks: 3, includedBlocks: 3, truncated: false })).toMatch(/all 3 sections/);
|
||||||
|
});
|
||||||
|
it('reports truncation with the counts', () => {
|
||||||
|
expect(contextNote({ text: 'x', totalBlocks: 10, includedBlocks: 4, truncated: true })).toMatch(/4 of 10 sections/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('documents: section builders', () => {
|
||||||
|
it('documentSection assembles profile + paragraph-blocked content', () => {
|
||||||
|
const section = documentSection(doc, { text: 'Clause 1.\n\nClause 2.', extracted: true });
|
||||||
|
expect(section.title).toBe('Document');
|
||||||
|
expect(section.blocks[0]).toContain('Document: Master Services Agreement');
|
||||||
|
expect(section.blocks).toContain('Clause 1.');
|
||||||
|
expect(section.blocks).toContain('Clause 2.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('documentSection uses the non-extractable reason as the sole content block', () => {
|
||||||
|
const section = documentSection(doc, { text: '', extracted: false, reason: 'binary - OCR out of scope' });
|
||||||
|
expect(section.blocks).toHaveLength(2);
|
||||||
|
expect(section.blocks[1]).toBe('[binary - OCR out of scope]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('index / folder / workspace / search sections turn records into a windowed context', () => {
|
||||||
|
const ws: Workspace = { id: 'W1', name: 'Acme', description: '', client: 'Acme', matter: 'M1', database: 'ACTIVE_US' };
|
||||||
|
const folders: FolderEntry[] = [{ id: 'F1', name: 'Contracts' }];
|
||||||
|
const ctx = buildContext([
|
||||||
|
workspaceSection([ws]),
|
||||||
|
folderSection(folders),
|
||||||
|
documentIndexSection([doc]),
|
||||||
|
searchResultSection([doc]),
|
||||||
|
]);
|
||||||
|
expect(ctx.totalBlocks).toBe(4);
|
||||||
|
expect(ctx.truncated).toBe(false);
|
||||||
|
expect(ctx.text).toContain('## Workspaces');
|
||||||
|
expect(ctx.text).toContain('## Folders');
|
||||||
|
expect(ctx.text).toContain('## Documents');
|
||||||
|
expect(ctx.text).toContain('## Search results');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { AiClient, ChatCompletion, ChatCompletionCreateParams, Model } from '@hanzo/ai';
|
||||||
|
import {
|
||||||
|
SYSTEM_PROMPT,
|
||||||
|
buildMessages,
|
||||||
|
extractContent,
|
||||||
|
ask,
|
||||||
|
listModels,
|
||||||
|
type MatterMeta,
|
||||||
|
} from '../src/hanzo.js';
|
||||||
|
import { buildContext, type DocumentContext } from '../src/documents.js';
|
||||||
|
|
||||||
|
const ctx: DocumentContext = buildContext([{ title: 'Document', blocks: ['Document: NDA — mutual'] }]);
|
||||||
|
const meta: MatterMeta = { workspaceName: 'Acme / Falcon', matterNumber: '2026-0042', client: 'Acme Corp' };
|
||||||
|
|
||||||
|
// completion builds a minimal @hanzo/ai ChatCompletion with the given content.
|
||||||
|
function completion(content: ChatCompletion['choices'][number]['message']['content']): ChatCompletion {
|
||||||
|
return {
|
||||||
|
id: 'c1',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 0,
|
||||||
|
model: 'zen5',
|
||||||
|
choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockClient captures the params the code sends and returns a canned completion.
|
||||||
|
function mockClient(reply: ChatCompletion, models: Model[] = []) {
|
||||||
|
const create = vi.fn(async (_p: ChatCompletionCreateParams, _o?: { signal?: AbortSignal }) => reply);
|
||||||
|
const list = vi.fn(async (_o?: { signal?: AbortSignal }) => models);
|
||||||
|
const client = { chat: { completions: { create } }, models: { list } } as unknown as AiClient;
|
||||||
|
return { client, create, list };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('hanzo: buildMessages', () => {
|
||||||
|
it('produces a grounded system prompt + a fenced user turn with matter meta + context note', () => {
|
||||||
|
const msgs = buildMessages('Does this NDA survive termination?', ctx, meta);
|
||||||
|
expect(msgs).toHaveLength(2);
|
||||||
|
expect(msgs[0].role).toBe('system');
|
||||||
|
expect(msgs[0].content).toBe(SYSTEM_PROMPT);
|
||||||
|
const user = String(msgs[1].content);
|
||||||
|
expect(user).toContain('Workspace: Acme / Falcon');
|
||||||
|
expect(user).toContain('Matter #: 2026-0042');
|
||||||
|
expect(user).toContain('Client: Acme Corp');
|
||||||
|
expect(user).toContain('---- document records ----');
|
||||||
|
expect(user).toContain('Document: NDA — mutual');
|
||||||
|
expect(user).toContain('---- task ----');
|
||||||
|
expect(user).toContain('Does this NDA survive termination?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the header block entirely when meta is undefined', () => {
|
||||||
|
const user = String(buildMessages('q', ctx).at(1)!.content);
|
||||||
|
expect(user).not.toContain('Workspace:');
|
||||||
|
expect(user).not.toContain('Matter #:');
|
||||||
|
expect(user).toContain('---- task ----');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SYSTEM_PROMPT forbids inventing terms and disclaims legal advice + affirms confidentiality', () => {
|
||||||
|
expect(SYSTEM_PROMPT).toMatch(/never invent/i);
|
||||||
|
expect(SYSTEM_PROMPT).toMatch(/not legal advice/i);
|
||||||
|
expect(SYSTEM_PROMPT).toMatch(/confidential/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hanzo: extractContent', () => {
|
||||||
|
it('reads a plain string content', () => {
|
||||||
|
expect(extractContent(completion('hello'))).toBe('hello');
|
||||||
|
});
|
||||||
|
it('concatenates text parts of an array content', () => {
|
||||||
|
const parts = [
|
||||||
|
{ type: 'text', text: 'a' },
|
||||||
|
{ type: 'image_url', image_url: { url: 'x' } },
|
||||||
|
{ type: 'text', text: 'b' },
|
||||||
|
] as any;
|
||||||
|
expect(extractContent(completion(parts))).toBe('ab');
|
||||||
|
});
|
||||||
|
it('throws on empty content', () => {
|
||||||
|
expect(() => extractContent(completion(''))).toThrow(/no content/);
|
||||||
|
expect(() => extractContent(completion(undefined))).toThrow(/no content/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hanzo: ask', () => {
|
||||||
|
it('sends model + messages non-streaming through the injected client and returns the text', async () => {
|
||||||
|
const { client, create } = mockClient(completion('the NDA survives for 3 years'));
|
||||||
|
const out = await ask('Does this NDA survive termination?', ctx, meta, { client, model: 'zen5', temperature: 0.2 });
|
||||||
|
expect(out).toBe('the NDA survives for 3 years');
|
||||||
|
expect(create).toHaveBeenCalledOnce();
|
||||||
|
const [params, options] = create.mock.calls[0];
|
||||||
|
expect(params.model).toBe('zen5');
|
||||||
|
expect(params.stream).toBe(false);
|
||||||
|
expect(params.temperature).toBe(0.2);
|
||||||
|
expect(params.messages[0].role).toBe('system');
|
||||||
|
expect(options).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits temperature from the wire when not provided', async () => {
|
||||||
|
const { client, create } = mockClient(completion('ok'));
|
||||||
|
await ask('q', ctx, undefined, { client });
|
||||||
|
expect('temperature' in create.mock.calls[0][0]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults the model to zen5', async () => {
|
||||||
|
const { client, create } = mockClient(completion('ok'));
|
||||||
|
await ask('q', ctx, undefined, { client });
|
||||||
|
expect(create.mock.calls[0][0].model).toBe('zen5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates an abort signal to the client', async () => {
|
||||||
|
const { client, create } = mockClient(completion('ok'));
|
||||||
|
const controller = new AbortController();
|
||||||
|
await ask('q', ctx, undefined, { client, signal: controller.signal });
|
||||||
|
expect(create.mock.calls[0][1]?.signal).toBe(controller.signal);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hanzo: listModels', () => {
|
||||||
|
it('maps the model catalog to ids', async () => {
|
||||||
|
const models: Model[] = [
|
||||||
|
{ id: 'zen5', object: 'model' },
|
||||||
|
{ id: 'zen5-pro', object: 'model' },
|
||||||
|
];
|
||||||
|
const { client } = mockClient(completion('x'), models);
|
||||||
|
expect(await listModels({ client })).toEqual(['zen5', 'zen5-pro']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
AUTH_HEADER,
|
||||||
|
DEFAULT_LIMIT,
|
||||||
|
pageParams,
|
||||||
|
parsePaging,
|
||||||
|
unwrap,
|
||||||
|
libBase,
|
||||||
|
enc,
|
||||||
|
listLibraries,
|
||||||
|
listWorkspaces,
|
||||||
|
getWorkspace,
|
||||||
|
listFolderChildren,
|
||||||
|
getDocument,
|
||||||
|
getDocumentContent,
|
||||||
|
updateDocumentProfile,
|
||||||
|
search,
|
||||||
|
type Scope,
|
||||||
|
} from '../src/imanage-api.js';
|
||||||
|
import { apiBaseUrl } from '../src/config.js';
|
||||||
|
|
||||||
|
const scope: Scope = {
|
||||||
|
apiBase: apiBaseUrl('cloudimanage.com'),
|
||||||
|
accessToken: 'at-123',
|
||||||
|
customerId: 1,
|
||||||
|
libraryId: 'ACTIVE_US',
|
||||||
|
};
|
||||||
|
|
||||||
|
function q(url: string): URLSearchParams {
|
||||||
|
return new URL(url).searchParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('api: pageParams (offset/limit)', () => {
|
||||||
|
it('emits nothing for no page', () => {
|
||||||
|
expect(pageParams(undefined)).toEqual({});
|
||||||
|
});
|
||||||
|
it('emits offset + limit when set', () => {
|
||||||
|
expect(pageParams({ offset: 50, limit: 25 })).toEqual({ offset: '50', limit: '25' });
|
||||||
|
});
|
||||||
|
it('clamps limit to the documented max', () => {
|
||||||
|
expect(pageParams({ limit: 500 }).limit).toBe(String(DEFAULT_LIMIT));
|
||||||
|
});
|
||||||
|
it('ignores non-positive values', () => {
|
||||||
|
expect(pageParams({ offset: 0, limit: -5 })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api: libBase + enc', () => {
|
||||||
|
it('builds the customer/library path prefix', () => {
|
||||||
|
expect(libBase({ customerId: 1, libraryId: 'ACTIVE_US' })).toBe('/customers/1/libraries/ACTIVE_US');
|
||||||
|
});
|
||||||
|
it('encodes ids defensively: a slash cannot traverse the path; iManage’s ! and . pass through as valid path chars', () => {
|
||||||
|
expect(enc('ACTIVE_US!4567.1')).toBe('ACTIVE_US!4567.1');
|
||||||
|
expect(enc('a/b')).toBe('a%2Fb');
|
||||||
|
expect(enc('a b')).toBe('a%20b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api: auth scoping', () => {
|
||||||
|
it('every read carries the X-Auth-Token header (never Authorization: Bearer)', () => {
|
||||||
|
for (const req of [
|
||||||
|
listLibraries(scope),
|
||||||
|
listWorkspaces(scope),
|
||||||
|
getWorkspace(scope, 'W1'),
|
||||||
|
listFolderChildren(scope, 'F1'),
|
||||||
|
getDocument(scope, 'D1'),
|
||||||
|
getDocumentContent(scope, 'D1'),
|
||||||
|
search(scope, 'nda'),
|
||||||
|
]) {
|
||||||
|
expect(req.headers[AUTH_HEADER]).toBe('at-123');
|
||||||
|
expect(req.headers.Authorization).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it('AUTH_HEADER is exactly X-Auth-Token', () => {
|
||||||
|
expect(AUTH_HEADER).toBe('X-Auth-Token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api: libraries + workspaces', () => {
|
||||||
|
it('lists libraries under the customer (no library in path)', () => {
|
||||||
|
const req = listLibraries(scope);
|
||||||
|
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries');
|
||||||
|
});
|
||||||
|
it('lists workspaces scoped to customer + library with pagination', () => {
|
||||||
|
const req = listWorkspaces(scope, { offset: 100, limit: 50 });
|
||||||
|
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/workspaces');
|
||||||
|
expect(q(req.url).get('offset')).toBe('100');
|
||||||
|
expect(q(req.url).get('limit')).toBe('50');
|
||||||
|
});
|
||||||
|
it('gets one workspace by id', () => {
|
||||||
|
expect(new URL(getWorkspace(scope, 'W-7').url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/workspaces/W-7');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api: folder children (browse)', () => {
|
||||||
|
it('lists a container’s children (folders + documents) with pagination', () => {
|
||||||
|
const req = listFolderChildren(scope, 'FOLDER!9', { limit: 100 });
|
||||||
|
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/folders/FOLDER!9/children');
|
||||||
|
expect(q(req.url).get('limit')).toBe('100');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api: documents', () => {
|
||||||
|
it('gets a document profile by id', () => {
|
||||||
|
const req = getDocument(scope, 'ACTIVE_US!4567.1');
|
||||||
|
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/documents/ACTIVE_US!4567.1');
|
||||||
|
});
|
||||||
|
it('gets document content via /download', () => {
|
||||||
|
const req = getDocumentContent(scope, 'ACTIVE_US!4567.1');
|
||||||
|
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/documents/ACTIVE_US!4567.1/download');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api: updateDocumentProfile (the one write path)', () => {
|
||||||
|
it('PATCHes a data-wrapped profile patch', () => {
|
||||||
|
const req = updateDocumentProfile(scope, 'D1', { comment: 'AI summary' });
|
||||||
|
expect(req.method).toBe('PATCH');
|
||||||
|
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/documents/D1');
|
||||||
|
expect(req.headers['Content-Type']).toBe('application/json');
|
||||||
|
expect(req.headers[AUTH_HEADER]).toBe('at-123');
|
||||||
|
expect(JSON.parse(req.body)).toEqual({ data: { comment: 'AI summary' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api: search', () => {
|
||||||
|
it('searches documents across the library with q + pagination', () => {
|
||||||
|
const req = search(scope, 'master services agreement', { limit: 25, offset: 25 });
|
||||||
|
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/documents/search');
|
||||||
|
expect(q(req.url).get('q')).toBe('master services agreement');
|
||||||
|
expect(q(req.url).get('limit')).toBe('25');
|
||||||
|
expect(q(req.url).get('offset')).toBe('25');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api: unwrap envelope', () => {
|
||||||
|
it('returns the data payload of an envelope', () => {
|
||||||
|
expect(unwrap({ data: { id: 'x' } })).toEqual({ id: 'x' });
|
||||||
|
expect(unwrap({ data: [1, 2] })).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
it('returns a bare value unchanged', () => {
|
||||||
|
expect(unwrap([1, 2])).toEqual([1, 2]);
|
||||||
|
expect(unwrap({ id: 'y' })).toEqual({ id: 'y' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api: parsePaging (body-based, not header)', () => {
|
||||||
|
it('reads total_count and computes hasMore from offset+returned', () => {
|
||||||
|
expect(parsePaging({ total_count: 137 }, 50, 0)).toEqual({ total: 137, hasMore: true });
|
||||||
|
expect(parsePaging({ total_count: 40 }, 40, 0)).toEqual({ total: 40, hasMore: false });
|
||||||
|
});
|
||||||
|
it('honours an explicit overflow flag', () => {
|
||||||
|
expect(parsePaging({ overflow: true }, 50, 0).hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
it('honours a next cursor', () => {
|
||||||
|
expect(parsePaging({ next: 'https://x/page2' }, 50, 0).hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
it('reads count as a total fallback and defaults to 0', () => {
|
||||||
|
expect(parsePaging({ count: 9 }, 9, 0).total).toBe(9);
|
||||||
|
expect(parsePaging({}, 0, 0)).toEqual({ total: 0, hasMore: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
authorizeUrl,
|
||||||
|
tokenUrl,
|
||||||
|
tokenExchange,
|
||||||
|
refreshExchange,
|
||||||
|
parseTokenResponse,
|
||||||
|
tokenExpiresAt,
|
||||||
|
isExpired,
|
||||||
|
} from '../src/imanage-oauth.js';
|
||||||
|
|
||||||
|
describe('oauth: authorizeUrl', () => {
|
||||||
|
it('builds the control-center consent URL with the standard params', () => {
|
||||||
|
const url = new URL(
|
||||||
|
authorizeUrl({
|
||||||
|
host: 'cloudimanage.com',
|
||||||
|
clientId: 'cid',
|
||||||
|
redirectUri: 'https://imanage.hanzo.ai/oauth/callback',
|
||||||
|
state: 'st-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(url.origin).toBe('https://cloudimanage.com');
|
||||||
|
expect(url.pathname).toBe('/auth/oauth2/authorize');
|
||||||
|
expect(url.searchParams.get('response_type')).toBe('code');
|
||||||
|
expect(url.searchParams.get('client_id')).toBe('cid');
|
||||||
|
expect(url.searchParams.get('redirect_uri')).toBe('https://imanage.hanzo.ai/oauth/callback');
|
||||||
|
expect(url.searchParams.get('state')).toBe('st-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a bare host and omits scope when empty', () => {
|
||||||
|
const url = new URL(
|
||||||
|
authorizeUrl({ host: 'work.firm.com', clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: [] }),
|
||||||
|
);
|
||||||
|
expect(url.origin).toBe('https://work.firm.com');
|
||||||
|
expect(url.searchParams.has('scope')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('space-joins scopes when present', () => {
|
||||||
|
const url = new URL(
|
||||||
|
authorizeUrl({ host: 'cloudimanage.com', clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: ['user', 'admin'] }),
|
||||||
|
);
|
||||||
|
expect(url.searchParams.get('scope')).toBe('user admin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('oauth: token endpoints', () => {
|
||||||
|
it('resolves the token URL on the control-center host', () => {
|
||||||
|
expect(tokenUrl('cloudimanage.com')).toBe('https://cloudimanage.com/auth/oauth2/token');
|
||||||
|
expect(tokenUrl('https://work.firm.com/')).toBe('https://work.firm.com/auth/oauth2/token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('oauth: tokenExchange', () => {
|
||||||
|
it('form-encodes the code grant with client credentials + redirect_uri in the body', () => {
|
||||||
|
const req = tokenExchange({
|
||||||
|
host: 'cloudimanage.com',
|
||||||
|
clientId: 'cid',
|
||||||
|
clientSecret: 'sec',
|
||||||
|
code: 'the-code',
|
||||||
|
redirectUri: 'https://imanage.hanzo.ai/oauth/callback',
|
||||||
|
});
|
||||||
|
expect(req.url).toBe('https://cloudimanage.com/auth/oauth2/token');
|
||||||
|
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||||
|
const body = new URLSearchParams(req.body);
|
||||||
|
expect(body.get('grant_type')).toBe('authorization_code');
|
||||||
|
expect(body.get('code')).toBe('the-code');
|
||||||
|
expect(body.get('client_id')).toBe('cid');
|
||||||
|
expect(body.get('client_secret')).toBe('sec');
|
||||||
|
expect(body.get('redirect_uri')).toBe('https://imanage.hanzo.ai/oauth/callback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never puts the secret in the URL', () => {
|
||||||
|
const req = tokenExchange({ host: 'cloudimanage.com', clientId: 'c', clientSecret: 'SUPER', code: 'x', redirectUri: 'https://x/cb' });
|
||||||
|
expect(req.url).not.toContain('SUPER');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('oauth: refreshExchange', () => {
|
||||||
|
it('form-encodes the refresh grant with client credentials', () => {
|
||||||
|
const req = refreshExchange({ host: 'cloudimanage.com', clientId: 'cid', clientSecret: 'sec', refreshToken: 'r-1' });
|
||||||
|
expect(req.url).toBe('https://cloudimanage.com/auth/oauth2/token');
|
||||||
|
const body = new URLSearchParams(req.body);
|
||||||
|
expect(body.get('grant_type')).toBe('refresh_token');
|
||||||
|
expect(body.get('refresh_token')).toBe('r-1');
|
||||||
|
expect(body.get('client_id')).toBe('cid');
|
||||||
|
expect(body.get('client_secret')).toBe('sec');
|
||||||
|
expect(body.has('code')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('oauth: parseTokenResponse', () => {
|
||||||
|
it('parses a full token set', () => {
|
||||||
|
const t = parseTokenResponse({
|
||||||
|
access_token: 'at',
|
||||||
|
refresh_token: 'rt',
|
||||||
|
expires_in: 3600,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
scope: 'user',
|
||||||
|
});
|
||||||
|
expect(t.access_token).toBe('at');
|
||||||
|
expect(t.refresh_token).toBe('rt');
|
||||||
|
expect(t.expires_in).toBe(3600);
|
||||||
|
expect(t.token_type).toBe('Bearer');
|
||||||
|
expect(t.scope).toBe('user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on an iManage error payload with the description', () => {
|
||||||
|
expect(() => parseTokenResponse({ error: 'invalid_grant', error_description: 'code used' })).toThrow(/code used/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when access_token is missing or empty', () => {
|
||||||
|
expect(() => parseTokenResponse({})).toThrow(/missing access_token/);
|
||||||
|
expect(() => parseTokenResponse({ access_token: '' })).toThrow(/missing access_token/);
|
||||||
|
expect(() => parseTokenResponse(null)).toThrow(/empty token response/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops non-string/non-number optional fields defensively', () => {
|
||||||
|
const t = parseTokenResponse({ access_token: 'at', refresh_token: 123, expires_in: 'soon' });
|
||||||
|
expect(t.refresh_token).toBeUndefined();
|
||||||
|
expect(t.expires_in).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('oauth: expiry math (minted-at based — iManage sends no issued-at)', () => {
|
||||||
|
it('computes expiry from mintedAt + expires_in minus skew', () => {
|
||||||
|
const t = { access_token: 'a', expires_in: 3600 };
|
||||||
|
expect(tokenExpiresAt(t, 1000, 60)).toBe(1000 + 3600 - 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a token with no expires_in as already at expiry (minus skew)', () => {
|
||||||
|
const t = { access_token: 'a' };
|
||||||
|
expect(tokenExpiresAt(t, 5000, 0)).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports not-expired before, expired at/after the skewed boundary', () => {
|
||||||
|
const t = { access_token: 'a', expires_in: 3600 };
|
||||||
|
const minted = 1000;
|
||||||
|
const boundary = minted + 3600 - 60;
|
||||||
|
expect(isExpired(t, minted, boundary - 1, 60)).toBe(false);
|
||||||
|
expect(isExpired(t, minted, boundary, 60)).toBe(true);
|
||||||
|
expect(isExpired(t, minted, boundary + 100, 60)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { parseLaunchContext, proxyUrl, createProxyClient } from '../src/panel.js';
|
||||||
|
|
||||||
|
describe('panel: parseLaunchContext', () => {
|
||||||
|
it('reads snake_case customer/library/workspace/document ids', () => {
|
||||||
|
expect(parseLaunchContext('?customer_id=1&library_id=ACTIVE_US&workspace_id=W1&document_id=D1')).toEqual({
|
||||||
|
customerId: '1',
|
||||||
|
libraryId: 'ACTIVE_US',
|
||||||
|
workspaceId: 'W1',
|
||||||
|
documentId: 'D1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('accepts camelCase fallbacks and defaults to empty', () => {
|
||||||
|
expect(parseLaunchContext('?customerId=7&libraryId=L')).toEqual({
|
||||||
|
customerId: '7',
|
||||||
|
libraryId: 'L',
|
||||||
|
workspaceId: '',
|
||||||
|
documentId: '',
|
||||||
|
});
|
||||||
|
expect(parseLaunchContext('')).toEqual({ customerId: '', libraryId: '', workspaceId: '', documentId: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('panel: proxyUrl', () => {
|
||||||
|
it('prefixes /proxy and appends a query, dropping empties', () => {
|
||||||
|
expect(proxyUrl('', '/customers/1/libraries/ACTIVE_US/workspaces', { limit: '100', offset: '' })).toBe(
|
||||||
|
'/proxy/customers/1/libraries/ACTIVE_US/workspaces?limit=100',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('strips a trailing slash from the base', () => {
|
||||||
|
expect(proxyUrl('https://imanage.hanzo.ai/', '/x')).toBe('https://imanage.hanzo.ai/proxy/x');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// jsonResponse builds a fetch Response-like from an iManage envelope body.
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
function textResponse(body: string, status = 200): Response {
|
||||||
|
return new Response(body, { status, headers: { 'Content-Type': 'text/plain' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchMockOf wraps a responder in a fetch-typed mock so calls[i] carries the
|
||||||
|
// (url, init) arg tuple TypeScript expects.
|
||||||
|
function fetchMockOf(responder: () => Response) {
|
||||||
|
return vi.fn((_url: RequestInfo | URL, _init?: RequestInit) => Promise.resolve(responder()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = { customerId: '1', libraryId: 'ACTIVE_US' };
|
||||||
|
|
||||||
|
describe('panel: createProxyClient — scoping + shaping', () => {
|
||||||
|
it('lists workspaces with the customer/library path, credentials, parsed items + paging', async () => {
|
||||||
|
const fetchMock = fetchMockOf(() =>
|
||||||
|
jsonResponse({ data: [{ id: 'ACTIVE_US!12', name: 'Acme / Falcon', client: 'Acme', matter: 'M1' }], total_count: 1 }),
|
||||||
|
);
|
||||||
|
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
|
||||||
|
const { items, paging } = await client.listWorkspaces({ limit: 100 });
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].name).toBe('Acme / Falcon');
|
||||||
|
expect(paging.total).toBe(1);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(String(url)).toBe('/proxy/customers/1/libraries/ACTIVE_US/workspaces?limit=100');
|
||||||
|
expect((init as RequestInit).credentials).toBe('include');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets one workspace by id', async () => {
|
||||||
|
const fetchMock = fetchMockOf(() => jsonResponse({ data: { id: 'W1', name: 'Solo' } }));
|
||||||
|
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
|
||||||
|
const ws = await client.getWorkspace('W1');
|
||||||
|
expect(ws.name).toBe('Solo');
|
||||||
|
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/customers/1/libraries/ACTIVE_US/workspaces/W1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists folder children split into folders + documents', async () => {
|
||||||
|
const fetchMock = fetchMockOf(() =>
|
||||||
|
jsonResponse({
|
||||||
|
data: [
|
||||||
|
{ id: 'F1', name: 'Contracts', type: 'folder' },
|
||||||
|
{ id: 'ACTIVE_US!4567.1', name: 'MSA', type: 'document', extension: 'DOCX' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
|
||||||
|
const { items } = await client.listFolderChildren('W1', { limit: 100 });
|
||||||
|
expect(items.folders.map((f) => f.id)).toEqual(['F1']);
|
||||||
|
expect(items.documents.map((d) => d.id)).toEqual(['ACTIVE_US!4567.1']);
|
||||||
|
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/customers/1/libraries/ACTIVE_US/folders/W1/children?limit=100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets a document profile and parses it', async () => {
|
||||||
|
const fetchMock = fetchMockOf(() =>
|
||||||
|
jsonResponse({ data: { id: 'ACTIVE_US!4567.1', name: 'MSA', extension: 'DOCX', author_description: 'Jane Smith' } }),
|
||||||
|
);
|
||||||
|
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
|
||||||
|
const doc = await client.getDocument('ACTIVE_US!4567.1');
|
||||||
|
expect(doc.name).toBe('MSA');
|
||||||
|
expect(doc.author).toBe('Jane Smith');
|
||||||
|
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/customers/1/libraries/ACTIVE_US/documents/ACTIVE_US!4567.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches document content via /download and extracts text', async () => {
|
||||||
|
const fetchMock = fetchMockOf(() => textResponse('Governing law: New York.\n\nTerm: 3 years.'));
|
||||||
|
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
|
||||||
|
const extracted = await client.getDocumentContent('ACTIVE_US!4567.1');
|
||||||
|
expect(extracted.extracted).toBe(true);
|
||||||
|
expect(extracted.text).toContain('Governing law: New York.');
|
||||||
|
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||||
|
'/proxy/customers/1/libraries/ACTIVE_US/documents/ACTIVE_US!4567.1/download',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches documents with q + pagination', async () => {
|
||||||
|
const fetchMock = fetchMockOf(() =>
|
||||||
|
jsonResponse({ data: [{ id: 'ACTIVE_US!4567.1', name: 'MSA', extension: 'DOCX' }], overflow: true }),
|
||||||
|
);
|
||||||
|
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
|
||||||
|
const { items, paging } = await client.search('services agreement', { limit: 50 });
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(paging.hasMore).toBe(true);
|
||||||
|
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||||
|
'/proxy/customers/1/libraries/ACTIVE_US/documents/search?q=services+agreement&limit=50',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateDocumentProfile PATCHes a data-wrapped body (the one write path)', async () => {
|
||||||
|
const fetchMock = fetchMockOf(() => jsonResponse({ data: { id: 'D1' } }));
|
||||||
|
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
|
||||||
|
await client.updateDocumentProfile('D1', { comment: 'AI summary' });
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(String(url)).toBe('/proxy/customers/1/libraries/ACTIVE_US/documents/D1');
|
||||||
|
expect((init as RequestInit).method).toBe('PATCH');
|
||||||
|
expect((init as RequestInit).credentials).toBe('include');
|
||||||
|
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ data: { comment: 'AI summary' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a proxy error with status + message', async () => {
|
||||||
|
const fetchMock = fetchMockOf(() => jsonResponse({ error: 'not authenticated' }, 401));
|
||||||
|
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
|
||||||
|
await expect(client.listWorkspaces()).rejects.toThrow(/401.*not authenticated/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseLibraries,
|
||||||
|
parseWorkspaces,
|
||||||
|
parseWorkspaceDetail,
|
||||||
|
parseFolderChildren,
|
||||||
|
parseDocument,
|
||||||
|
parseSearchResults,
|
||||||
|
} from '../src/imanage-api.js';
|
||||||
|
|
||||||
|
// A realistic iManage Work API v2 document profile (envelope-wrapped, trimmed to
|
||||||
|
// the fields we surface). iManage separates coded fields from *_description labels.
|
||||||
|
const documentEnvelope = {
|
||||||
|
data: {
|
||||||
|
id: 'ACTIVE_US!4567.1',
|
||||||
|
name: 'Master Services Agreement - Acme Corp',
|
||||||
|
extension: 'DOCX',
|
||||||
|
size: 48213,
|
||||||
|
author: 'jsmith',
|
||||||
|
author_description: 'Jane Smith',
|
||||||
|
class: 'AGR',
|
||||||
|
class_description: 'Agreement',
|
||||||
|
type: 'DOC',
|
||||||
|
type_description: 'Document',
|
||||||
|
version: 1,
|
||||||
|
edit_date: '2026-06-20T14:00:00Z',
|
||||||
|
comment: 'Final executed copy',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parse: libraries', () => {
|
||||||
|
it('reads id + name from an envelope and drops idless rows', () => {
|
||||||
|
const libs = parseLibraries({ data: [{ id: 'ACTIVE_US', name: 'Active (US)' }, { name: 'no id' }] });
|
||||||
|
expect(libs).toEqual([{ id: 'ACTIVE_US', name: 'Active (US)' }]);
|
||||||
|
});
|
||||||
|
it('falls id back to name (and vice versa)', () => {
|
||||||
|
expect(parseLibraries([{ id: 'X' }])[0]).toEqual({ id: 'X', name: 'X' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parse: workspaces', () => {
|
||||||
|
it('reads the matter fields from an envelope array and drops idless rows', () => {
|
||||||
|
const wss = parseWorkspaces({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'ACTIVE_US!12',
|
||||||
|
name: 'Acme Corp / Project Falcon',
|
||||||
|
wstype: 'workspace',
|
||||||
|
description: 'M&A due diligence',
|
||||||
|
client: 'Acme Corp',
|
||||||
|
matter: '2026-0042',
|
||||||
|
database: 'ACTIVE_US',
|
||||||
|
},
|
||||||
|
{ name: 'no id' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(wss).toHaveLength(1);
|
||||||
|
expect(wss[0]).toEqual({
|
||||||
|
id: 'ACTIVE_US!12',
|
||||||
|
name: 'Acme Corp / Project Falcon',
|
||||||
|
description: 'M&A due diligence',
|
||||||
|
client: 'Acme Corp',
|
||||||
|
matter: '2026-0042',
|
||||||
|
database: 'ACTIVE_US',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('reads client/matter from custom1/custom2 fallbacks', () => {
|
||||||
|
const w = parseWorkspaces([{ id: 'W1', name: 'W', custom1: 'ClientCo', custom2: 'M-9' }])[0];
|
||||||
|
expect(w.client).toBe('ClientCo');
|
||||||
|
expect(w.matter).toBe('M-9');
|
||||||
|
});
|
||||||
|
it('parseWorkspaceDetail unwraps a single workspace envelope', () => {
|
||||||
|
const w = parseWorkspaceDetail({ data: { id: 'W2', name: 'Solo' } });
|
||||||
|
expect(w.id).toBe('W2');
|
||||||
|
expect(w.name).toBe('Solo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parse: document profile', () => {
|
||||||
|
it('unwraps the envelope and prefers *_description labels; lowercases the extension', () => {
|
||||||
|
const doc = parseDocument(documentEnvelope);
|
||||||
|
expect(doc.id).toBe('ACTIVE_US!4567.1');
|
||||||
|
expect(doc.name).toBe('Master Services Agreement - Acme Corp');
|
||||||
|
expect(doc.extension).toBe('docx');
|
||||||
|
expect(doc.size).toBe(48213);
|
||||||
|
expect(doc.author).toBe('Jane Smith');
|
||||||
|
expect(doc.class).toBe('Agreement');
|
||||||
|
expect(doc.type).toBe('Document');
|
||||||
|
expect(doc.version).toBe(1);
|
||||||
|
expect(doc.editDate).toBe('2026-06-20T14:00:00Z');
|
||||||
|
expect(doc.comment).toBe('Final executed copy');
|
||||||
|
});
|
||||||
|
it('tolerates a bare (unwrapped) profile and missing fields', () => {
|
||||||
|
const doc = parseDocument({ id: 'D1', name: 'X' });
|
||||||
|
expect(doc.id).toBe('D1');
|
||||||
|
expect(doc.extension).toBe('');
|
||||||
|
expect(doc.size).toBe(0);
|
||||||
|
expect(doc.author).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parse: folder children (folders + documents split)', () => {
|
||||||
|
const children = {
|
||||||
|
data: [
|
||||||
|
{ id: 'FOLDER!1', name: 'Contracts', type: 'folder', wstype: 'folder' },
|
||||||
|
{
|
||||||
|
id: 'ACTIVE_US!4567.1',
|
||||||
|
name: 'MSA - Acme',
|
||||||
|
type: 'document',
|
||||||
|
extension: 'DOCX',
|
||||||
|
author_description: 'Jane Smith',
|
||||||
|
edit_date: '2026-06-20',
|
||||||
|
},
|
||||||
|
{ id: 'ACTIVE_US!4568.2', name: 'NDA', extension: 'PDF' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('separates typed folders from documents and untyped-with-extension rows', () => {
|
||||||
|
const { folders, documents } = parseFolderChildren(children);
|
||||||
|
expect(folders).toEqual([{ id: 'FOLDER!1', name: 'Contracts' }]);
|
||||||
|
expect(documents.map((d) => d.id)).toEqual(['ACTIVE_US!4567.1', 'ACTIVE_US!4568.2']);
|
||||||
|
expect(documents[0].author).toBe('Jane Smith');
|
||||||
|
expect(documents[1].extension).toBe('pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an untyped row with no extension as a folder (never guessed a document)', () => {
|
||||||
|
const { folders, documents } = parseFolderChildren([{ id: 'C1', name: 'Correspondence' }]);
|
||||||
|
expect(folders).toEqual([{ id: 'C1', name: 'Correspondence' }]);
|
||||||
|
expect(documents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a nested { document } child object', () => {
|
||||||
|
const { documents } = parseFolderChildren([
|
||||||
|
{ type: 'document', document: { id: 'D9', name: 'Deed', extension: 'PDF' } },
|
||||||
|
]);
|
||||||
|
expect(documents[0]).toMatchObject({ id: 'D9', name: 'Deed', extension: 'pdf' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops idless rows from both buckets', () => {
|
||||||
|
const { folders, documents } = parseFolderChildren([{ type: 'folder', name: 'nameless' }, { extension: 'DOCX', name: 'no id doc' }]);
|
||||||
|
expect(folders).toEqual([]);
|
||||||
|
expect(documents).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parse: search results', () => {
|
||||||
|
it('reads document rows from an envelope and drops idless rows', () => {
|
||||||
|
const results = parseSearchResults({
|
||||||
|
data: [
|
||||||
|
{ id: 'ACTIVE_US!4567.1', name: 'MSA - Acme', extension: 'DOCX', author_description: 'Jane Smith', class_description: 'Agreement' },
|
||||||
|
{ name: 'no id' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].name).toBe('MSA - Acme');
|
||||||
|
expect(results[0].class).toBe('Agreement');
|
||||||
|
});
|
||||||
|
it('handles a bare array shape', () => {
|
||||||
|
expect(parseSearchResults([{ id: 'D1', name: 'X', extension: 'PDF' }])).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ packages:
|
|||||||
- 'packages/gitlab'
|
- 'packages/gitlab'
|
||||||
- 'packages/gworkspace'
|
- 'packages/gworkspace'
|
||||||
- 'packages/hubspot'
|
- 'packages/hubspot'
|
||||||
|
- 'packages/imanage'
|
||||||
- 'packages/jetbrains'
|
- 'packages/jetbrains'
|
||||||
- 'packages/jupyter'
|
- 'packages/jupyter'
|
||||||
- 'packages/mcp'
|
- 'packages/mcp'
|
||||||
|
|||||||
Reference in New Issue
Block a user